未知(继上次中断)
This commit is contained in:
+1069
-132
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,33 @@
|
||||
class="home-alert"
|
||||
/>
|
||||
|
||||
<!-- 通知 -->
|
||||
<el-card class="notifications-card">
|
||||
<div class="notifications-header">
|
||||
<span class="notifications-title">{{ TEXT.modules.projectOverview.notificationsTitle }}</span>
|
||||
<span class="notifications-subtitle">{{ TEXT.modules.projectOverview.notificationsSubtitle }}</span>
|
||||
</div>
|
||||
<el-skeleton v-if="loading.notifications" :rows="3" animated />
|
||||
<StateEmpty v-else-if="!notifications.length" :description="TEXT.modules.projectOverview.notificationsEmpty" />
|
||||
<div v-else class="notification-list">
|
||||
<div class="notification-item" v-for="item in notifications" :key="item.id">
|
||||
<div class="notification-main">
|
||||
<div class="notification-title">
|
||||
<span class="notification-doc">{{ item.document_title }}</span>
|
||||
<span class="notification-version">V{{ item.version_no }}</span>
|
||||
</div>
|
||||
<div class="notification-meta">
|
||||
<span class="notification-time">{{ formatDate(item.effective_at || item.created_at) }}</span>
|
||||
<span v-if="item.change_summary" class="notification-summary">{{ item.change_summary }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button link type="primary" @click="openDocument(item.document_id)">
|
||||
{{ TEXT.common.actions.view }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 快捷模块 -->
|
||||
<div class="quick-nav-section">
|
||||
<QuickActions />
|
||||
@@ -83,24 +110,30 @@ import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { fetchFinanceSummary, fetchOverdueAesCount, fetchProgress } from "../api/dashboard";
|
||||
import { listNotifications } from "../api/notifications";
|
||||
import KpiCard from "../components/KpiCard.vue";
|
||||
import QuickActions from "../components/QuickActions.vue";
|
||||
import { List, Warning, Money } from "@element-plus/icons-vue";
|
||||
import StateEmpty from "../components/StateEmpty.vue";
|
||||
import { displayEnum } from "../utils/display";
|
||||
import { TEXT } from "../locales";
|
||||
import type { NotificationItem } from "../types/notifications";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
const router = useRouter();
|
||||
|
||||
const progress = ref<any>(null);
|
||||
const financeSummary = ref<any>(null);
|
||||
const overdueAes = ref<number>(0);
|
||||
const notifications = ref<NotificationItem[]>([]);
|
||||
|
||||
const loading = ref({
|
||||
progress: false,
|
||||
finance: false,
|
||||
overdueAes: false,
|
||||
notifications: false,
|
||||
});
|
||||
|
||||
const milestoneCompletionRate = computed(() => {
|
||||
@@ -123,20 +156,24 @@ const loadData = async () => {
|
||||
loading.value.progress = true;
|
||||
loading.value.finance = true;
|
||||
loading.value.overdueAes = true;
|
||||
loading.value.notifications = true;
|
||||
try {
|
||||
const [pRes, fRes, aesRes] = await Promise.allSettled([
|
||||
const [pRes, fRes, aesRes, nRes] = await Promise.allSettled([
|
||||
fetchProgress(studyId),
|
||||
fetchFinanceSummary(studyId),
|
||||
fetchOverdueAesCount(studyId),
|
||||
listNotifications(studyId, { limit: 5 }),
|
||||
]);
|
||||
|
||||
if (pRes.status === "fulfilled") progress.value = pRes.value.data;
|
||||
if (fRes.status === "fulfilled") financeSummary.value = fRes.value.data;
|
||||
if (aesRes.status === "fulfilled") overdueAes.value = aesRes.value.data?.total ?? 0;
|
||||
if (nRes.status === "fulfilled") notifications.value = nRes.value.data || [];
|
||||
} finally {
|
||||
loading.value.progress = false;
|
||||
loading.value.finance = false;
|
||||
loading.value.overdueAes = false;
|
||||
loading.value.notifications = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -144,6 +181,16 @@ const resetData = () => {
|
||||
progress.value = null;
|
||||
financeSummary.value = null;
|
||||
overdueAes.value = 0;
|
||||
notifications.value = [];
|
||||
};
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return TEXT.common.fallback;
|
||||
return value.replace("T", " ").replace("Z", "").split(".")[0];
|
||||
};
|
||||
|
||||
const openDocument = (documentId: string) => {
|
||||
router.push(`/documents/${documentId}`);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
@@ -217,6 +264,80 @@ watch(
|
||||
border-radius: var(--ctms-radius);
|
||||
}
|
||||
|
||||
.notifications-card {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.notifications-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.notifications-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.notifications-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--ctms-bg-muted);
|
||||
}
|
||||
|
||||
.notification-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.notification-version {
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--ctms-primary-light);
|
||||
color: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.notification-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.notification-summary {
|
||||
max-width: 360px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-nav-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h3>{{ TEXT.modules.adminUserApproval.title }}</h3>
|
||||
<p>{{ TEXT.modules.adminUserApproval.subtitle }}</p>
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<div class="filter-form">
|
||||
<el-tag type="info" effect="plain" class="filter-label-tag">{{ TEXT.common.fields.status }}</el-tag>
|
||||
<el-select v-model="status" style="width: 140px" @change="loadUsers" class="filter-select-comp">
|
||||
<el-option :label="TEXT.enums.userStatus.PENDING" value="PENDING" />
|
||||
<el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
|
||||
<el-option :label="TEXT.enums.userStatus.REJECTED" value="REJECTED" />
|
||||
<el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
|
||||
</el-select>
|
||||
<div class="filter-spacer"></div>
|
||||
</div>
|
||||
<el-select v-model="status" size="small" style="width: 180px" @change="loadUsers">
|
||||
<el-option :label="TEXT.enums.userStatus.PENDING" value="PENDING" />
|
||||
<el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
|
||||
<el-option :label="TEXT.enums.userStatus.REJECTED" value="REJECTED" />
|
||||
<el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="users" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="email" :label="TEXT.common.fields.email" min-width="200" />
|
||||
@@ -120,19 +120,34 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.header h3 {
|
||||
margin: 0;
|
||||
|
||||
.filter-label-tag {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ctms-text-secondary);
|
||||
padding: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.header p {
|
||||
margin: 4px 0 0;
|
||||
color: #6c7a89;
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,59 +1,48 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h3>{{ TEXT.modules.adminAuditLogs.title }}</h3>
|
||||
</div>
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="loadLogs" class="filter-select-comp">
|
||||
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.operatorId" clearable :placeholder="TEXT.modules.adminAuditLogs.filterOperator" filterable @change="loadLogs" class="filter-select-comp">
|
||||
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="loadLogs" style="width: 120px">
|
||||
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
|
||||
<el-option :label="TEXT.audit.resultFail" value="FAIL" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-date-picker
|
||||
v-model="filters.range"
|
||||
type="daterange"
|
||||
unlink-panels
|
||||
:start-placeholder="TEXT.modules.adminAuditLogs.rangeStart"
|
||||
:end-placeholder="TEXT.modules.adminAuditLogs.rangeEnd"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="date-range-picker-comp"
|
||||
@change="loadLogs"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<div class="ctms-action-group">
|
||||
<el-button v-if="isAdmin" type="warning" plain size="small" :loading="exportLoading" @click="confirmExport('system')">
|
||||
{{ TEXT.modules.adminAuditLogs.exportSystem }}
|
||||
</el-button>
|
||||
<el-button v-if="canProjectExport" type="warning" plain size="small" :loading="exportLoading" @click="confirmExport('project')">
|
||||
{{ TEXT.modules.adminAuditLogs.exportProject }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</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 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 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" class="ctms-filter-item w-32">
|
||||
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
|
||||
<el-option :label="TEXT.audit.resultFail" value="FAIL" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="filters.range"
|
||||
type="daterange"
|
||||
unlink-panels
|
||||
:start-placeholder="TEXT.modules.adminAuditLogs.rangeStart"
|
||||
:end-placeholder="TEXT.modules.adminAuditLogs.rangeEnd"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="ctms-filter-item date-range-picker"
|
||||
@change="loadLogs"
|
||||
/>
|
||||
</div>
|
||||
<div class="ctms-action-group">
|
||||
<el-button
|
||||
v-if="isAdmin"
|
||||
type="warning"
|
||||
plain
|
||||
size="small"
|
||||
:loading="exportLoading"
|
||||
@click="confirmExport('system')"
|
||||
>
|
||||
{{ TEXT.modules.adminAuditLogs.exportSystem }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canProjectExport"
|
||||
type="warning"
|
||||
plain
|
||||
size="small"
|
||||
:loading="exportLoading"
|
||||
@click="confirmExport('project')"
|
||||
>
|
||||
{{ TEXT.modules.adminAuditLogs.exportProject }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
@@ -334,53 +323,56 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 24px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
flex-direction: column;
|
||||
}
|
||||
.header h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
.ctms-table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ctms-filter-group {
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.ctms-filter-item {
|
||||
width: auto;
|
||||
|
||||
.filter-item-form {
|
||||
margin-bottom: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.filter-select-comp {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.date-range-picker-comp {
|
||||
width: 280px !important;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.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: 24px;
|
||||
text-align: right;
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -300,14 +300,26 @@ const handleLock = async () => {
|
||||
type: "warning",
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
const { value } = await ElMessageBox.prompt(TEXT.modules.adminProjects.lockConfirmPrompt, TEXT.modules.adminProjects.lockConfirmTitle, {
|
||||
confirmButtonText: TEXT.common.actions.confirm,
|
||||
cancelButtonText: TEXT.common.actions.cancel,
|
||||
inputPlaceholder: TEXT.modules.adminProjects.lockConfirmPlaceholder,
|
||||
inputValidator: (input: string) => input === "确认锁定" || TEXT.modules.adminProjects.lockConfirmMismatch,
|
||||
type: "warning",
|
||||
});
|
||||
if (value !== "确认锁定") {
|
||||
ElMessage.warning(TEXT.modules.adminProjects.lockConfirmMismatchWarning);
|
||||
return;
|
||||
}
|
||||
const { data } = await lockStudy(project.value.id);
|
||||
project.value = data as Study;
|
||||
syncForm(project.value);
|
||||
ElMessage.success("项目已锁定");
|
||||
} catch (err) {
|
||||
if (err !== "cancel") {
|
||||
ElMessage.error("锁定项目失败");
|
||||
if (err !== "cancel" && err !== "close") {
|
||||
const detail = (err as any)?.response?.data?.detail || "锁定项目失败";
|
||||
ElMessage.error(detail);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<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.projectCode" prop="code">
|
||||
<el-input v-model="form.code" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectCode" />
|
||||
</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>
|
||||
@@ -64,6 +67,7 @@ const formRef = ref<FormInstance>();
|
||||
const submitting = ref(false);
|
||||
const form = reactive({
|
||||
name: "",
|
||||
code: "",
|
||||
protocol_no: "",
|
||||
phase: "",
|
||||
status: "DRAFT",
|
||||
@@ -80,6 +84,7 @@ const rules = reactive<FormRules>({
|
||||
|
||||
const resetForm = () => {
|
||||
form.name = "";
|
||||
form.code = "";
|
||||
form.protocol_no = "";
|
||||
form.phase = "";
|
||||
form.status = "DRAFT";
|
||||
@@ -96,6 +101,7 @@ watch(
|
||||
resetForm();
|
||||
if (props.project) {
|
||||
form.name = props.project.name;
|
||||
form.code = props.project.code || "";
|
||||
form.protocol_no = props.project.protocol_no || "";
|
||||
form.phase = props.project.phase || "";
|
||||
form.status = props.project.status || "DRAFT";
|
||||
@@ -116,6 +122,7 @@ const onSubmit = async () => {
|
||||
if (props.project) {
|
||||
await updateStudy(props.project.id, {
|
||||
name: form.name,
|
||||
code: form.code,
|
||||
protocol_no: form.protocol_no,
|
||||
phase: form.phase,
|
||||
status: form.status,
|
||||
@@ -128,6 +135,7 @@ const onSubmit = async () => {
|
||||
} else {
|
||||
await createStudy({
|
||||
name: form.name,
|
||||
code: form.code,
|
||||
protocol_no: form.protocol_no,
|
||||
phase: form.phase,
|
||||
status: form.status,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h3>{{ TEXT.modules.adminProjectMembers.title }}</h3>
|
||||
<div class="sub" v-if="project">{{ TEXT.modules.adminProjectMembers.projectPrefix }}{{ project.code }} - {{ project.name }}</div>
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<div class="filter-form">
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openAdd" class="header-action-btn">
|
||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button type="primary" @click="openAdd">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}</el-button>
|
||||
</div>
|
||||
<el-table :data="memberRows" v-loading="loading" stripe>
|
||||
<el-table-column prop="full_name" :label="TEXT.modules.adminProjectMembers.username" min-width="160" />
|
||||
@@ -394,18 +395,35 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sub {
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #999;
|
||||
margin: 0 8px;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h3>{{ TEXT.modules.adminProjects.title }}</h3>
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<div class="filter-form">
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openCreate" class="header-action-btn">
|
||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}
|
||||
</el-button>
|
||||
</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="name" :label="TEXT.common.fields.projectName" min-width="300">
|
||||
@@ -13,6 +15,11 @@
|
||||
<el-link type="primary" @click="goDetail(scope.row)" class="font-medium">{{ scope.row.name }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="code" :label="TEXT.common.fields.projectCode" min-width="160">
|
||||
<template #default="scope">
|
||||
<span class="text-gray-500">{{ scope.row.code || "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" min-width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusTag(scope.row.status)" effect="plain" round>{{ statusLabel(scope.row.status) }}</el-tag>
|
||||
@@ -24,11 +31,6 @@
|
||||
<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" min-width="180" align="center">
|
||||
<template #default="scope">
|
||||
<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">
|
||||
@@ -69,7 +71,6 @@ import { fetchStudies, deleteStudy, lockStudy } from "../../api/studies";
|
||||
import type { Study } from "../../types/api";
|
||||
import ProjectForm from "./ProjectForm.vue";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const projects = ref<Study[]>([]);
|
||||
@@ -167,13 +168,25 @@ const handleLockToggle = async (study: Study) => {
|
||||
type: "warning",
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
const { value } = await ElMessageBox.prompt(TEXT.modules.adminProjects.lockConfirmPrompt, TEXT.modules.adminProjects.lockConfirmTitle, {
|
||||
confirmButtonText: TEXT.common.actions.confirm,
|
||||
cancelButtonText: TEXT.common.actions.cancel,
|
||||
inputPlaceholder: TEXT.modules.adminProjects.lockConfirmPlaceholder,
|
||||
inputValidator: (input: string) => input === "确认锁定" || TEXT.modules.adminProjects.lockConfirmMismatch,
|
||||
type: "warning",
|
||||
});
|
||||
if (value !== "确认锁定") {
|
||||
ElMessage.warning(TEXT.modules.adminProjects.lockConfirmMismatchWarning);
|
||||
return;
|
||||
}
|
||||
await lockStudy(study.id);
|
||||
ElMessage.success("项目已锁定");
|
||||
await loadProjects();
|
||||
} catch (err) {
|
||||
if (err !== "cancel") {
|
||||
ElMessage.error("锁定项目失败");
|
||||
if (err !== "cancel" && err !== "close") {
|
||||
const detail = (err as any)?.response?.data?.detail || "锁定项目失败";
|
||||
ElMessage.error(detail);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -204,27 +217,33 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sub {
|
||||
color: #6b7280;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 表格样式美化 */
|
||||
@@ -238,19 +257,18 @@ onMounted(() => {
|
||||
:deep(.el-table th.el-table__cell) {
|
||||
background-color: #f9fafb;
|
||||
font-weight: 600;
|
||||
height: 50px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
/* 增加单元格间距 */
|
||||
:deep(.el-table .el-table__cell) {
|
||||
padding: 16px 0;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 操作按钮美化 */
|
||||
.action-btn {
|
||||
font-size: 20px; /* 图标变更大 */
|
||||
padding: 6px;
|
||||
margin: 0 4px;
|
||||
font-size: 18px; /* 图标变更大 */
|
||||
padding: 4px;
|
||||
margin: 0 2px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
@@ -261,7 +279,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.enter-btn {
|
||||
font-size: 20px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h3>{{ TEXT.modules.adminSites.title }}</h3>
|
||||
<div class="sub" v-if="project">{{ TEXT.modules.adminSites.projectPrefix }}{{ project.code }} - {{ project.name }}</div>
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<div class="filter-form">
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openCreate" class="header-action-btn">
|
||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminSites.siteLabel }}
|
||||
</el-button>
|
||||
</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 :row-class-name="siteRowClass">
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.siteName" width="180" />
|
||||
@@ -266,17 +267,33 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sub {
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h3>{{ TEXT.modules.adminUsers.title }}</h3>
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<div class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
:placeholder="TEXT.common.placeholders.keyword"
|
||||
clearable
|
||||
class="filter-input-comp"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="statusFilter" :placeholder="TEXT.common.fields.status" style="width: 140px" clearable class="filter-select-comp" @change="loadUsers">
|
||||
<el-option :label="TEXT.enums.userStatus.PENDING" value="PENDING" />
|
||||
<el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
|
||||
<el-option :label="TEXT.enums.userStatus.REJECTED" value="REJECTED" />
|
||||
<el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-item-form">
|
||||
<el-button @click="loadUsers">{{ TEXT.common.actions.search }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openCreate" class="header-action-btn">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="users" stripe v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="email" :label="TEXT.common.fields.email" min-width="200" />
|
||||
<el-table-column prop="full_name" :label="TEXT.common.fields.name" min-width="140" />
|
||||
@@ -50,16 +70,15 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, total"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
v-model:current-page="page"
|
||||
@current-change="loadUsers"
|
||||
/>
|
||||
</div>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, total"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
v-model:current-page="page"
|
||||
@current-change="loadUsers"
|
||||
class="pagination"
|
||||
/>
|
||||
</el-card>
|
||||
<UserForm v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" />
|
||||
<UserResetPassword v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" />
|
||||
@@ -88,6 +107,8 @@ const loading = ref(false);
|
||||
const activeAdminCount = ref(0);
|
||||
const formVisible = ref(false);
|
||||
const resetVisible = ref(false);
|
||||
const searchKeyword = ref("");
|
||||
const statusFilter = ref("");
|
||||
const editingUser = ref<UserInfo | null>(null);
|
||||
const resetUser = ref<UserInfo | null>(null);
|
||||
const auth = useAuthStore();
|
||||
@@ -127,7 +148,12 @@ const loadUsers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [{ data }, { data: adminData }] = await Promise.all([
|
||||
fetchUsers({ skip: (page.value - 1) * pageSize.value, limit: pageSize.value }),
|
||||
fetchUsers({
|
||||
skip: (page.value - 1) * pageSize.value,
|
||||
limit: pageSize.value,
|
||||
keyword: searchKeyword.value || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
}),
|
||||
fetchUsers({ skip: 0, limit: 10000 }),
|
||||
]);
|
||||
const items = (data as any).items || [];
|
||||
@@ -252,20 +278,47 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sub {
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
|
||||
.filter-item-form {
|
||||
margin-bottom: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.filter-input-comp {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 12px;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,7 +27,12 @@
|
||||
<el-descriptions :column="3" border class="ctms-descriptions">
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.fields.docType">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.fields.site">{{ displaySite(detail.site_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.labels.currentEffective">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.labels.currentEffective">
|
||||
{{ currentEffectiveVersion?.version_no || TEXT.common.fallback }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="版本日期">
|
||||
{{ formatDateOnly(currentEffectiveVersion?.effective_at || currentEffectiveVersion?.created_at || null) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.updatedAt">{{ formatDate(detail.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
@@ -35,7 +40,7 @@
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange" class="ctms-tabs">
|
||||
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.versions" name="versions">
|
||||
<div class="ctms-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.fileVersionManagement.labels.versionTimeline }}</div>
|
||||
<div class="ctms-section-title"> </div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button type="primary" @click="openUpload" v-if="canAction('create_version')">
|
||||
<el-icon class="el-icon--left"><Upload /></el-icon>
|
||||
@@ -50,9 +55,9 @@
|
||||
<span class="font-medium">V{{ row.version_no }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.status" width="120">
|
||||
<el-table-column label="版本日期" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getVersionStatusType(row.status)" effect="light" size="small">{{ displayEnum(TEXT.enums.documentVersionStatus, row.status) }}</el-tag>
|
||||
<span class="text-secondary">{{ formatDateOnly(row.effective_at || row.created_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="change_summary" :label="TEXT.modules.fileVersionManagement.fields.changeSummary" min-width="200" show-overflow-tooltip />
|
||||
@@ -61,37 +66,29 @@
|
||||
<span>{{ displayCreator(row.created_by) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" :label="TEXT.modules.fileVersionManagement.labels.createdAt" width="180">
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.fields.parentVersion" width="140">
|
||||
<template #default="{ row }">
|
||||
<span class="text-secondary">{{ formatDate(row.created_at) }}</span>
|
||||
<span class="text-secondary">{{ displayParentVersion(row.parent_version_id) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.approvalSummary" min-width="220">
|
||||
<el-table-column prop="effective_at" :label="TEXT.modules.fileVersionManagement.columns.effectiveAt" width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="text-xs text-secondary lh-sm">
|
||||
<div v-if="row.submitted_at">{{ TEXT.modules.fileVersionManagement.labels.submitAt }}:{{ formatDate(row.submitted_at) }}</div>
|
||||
<div v-if="row.approved_at" class="mt-1">
|
||||
{{ TEXT.modules.fileVersionManagement.labels.approveAt }}:{{ formatDate(row.approved_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-secondary">{{ formatDate(row.effective_at || row.created_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="280" align="center">
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="downloadVersion(row)" v-if="canAction('view')">
|
||||
{{ TEXT.modules.fileVersionManagement.actions.download }}
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="openSubmit(row)" v-if="canAction('submit') && row.status === 'DRAFT'">
|
||||
{{ TEXT.modules.fileVersionManagement.actions.submit }}
|
||||
</el-button>
|
||||
<el-button link type="success" size="small" @click="approve(row)" v-if="canAction('approve') && row.status === 'SUBMITTED'">
|
||||
{{ TEXT.modules.fileVersionManagement.actions.approve }}
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="reject(row)" v-if="canAction('approve') && row.status === 'SUBMITTED'">
|
||||
{{ TEXT.modules.fileVersionManagement.actions.reject }}
|
||||
</el-button>
|
||||
<el-button link type="warning" size="small" @click="makeEffective(row)" v-if="canAction('approve') && row.status === 'APPROVED'">
|
||||
{{ TEXT.modules.fileVersionManagement.actions.makeEffective }}
|
||||
<el-button
|
||||
v-if="isAdmin"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="confirmDeleteVersion(row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -101,9 +98,9 @@
|
||||
|
||||
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.distributions" name="distributions">
|
||||
<div class="ctms-section-header">
|
||||
<div class="ctms-section-title">{{ TEXT.modules.fileVersionManagement.labels.distributionList }}</div>
|
||||
<div class="ctms-section-title"> </div>
|
||||
<div class="ctms-section-actions">
|
||||
<el-button type="primary" @click="openDistribute" v-if="canAction('distribute')">
|
||||
<el-button type="primary" @click="openDistribute">
|
||||
<el-icon class="el-icon--left"><Share /></el-icon>
|
||||
{{ TEXT.modules.fileVersionManagement.actions.distribute }}
|
||||
</el-button>
|
||||
@@ -121,48 +118,7 @@
|
||||
<span class="font-medium">{{ displayTarget(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.ackStats" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span>{{ TEXT.modules.fileVersionManagement.actions.ackReceived }}: {{ row.stats?.received || 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.overdue" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.stats?.overdue" type="danger" effect="dark" size="small">{{ TEXT.modules.fileVersionManagement.labels.overdue }}</el-tag>
|
||||
<span v-else class="text-secondary">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.dueAt" width="160">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'text-danger': row.stats?.overdue }">{{ formatDate(row.due_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.ackAction" width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="ack(row, 'RECEIVED')" v-if="canAction('ack')">
|
||||
{{ TEXT.modules.fileVersionManagement.actions.ackReceived }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.audit" name="audit">
|
||||
<el-card class="ctms-table-card">
|
||||
<el-table :data="auditLogs" v-loading="auditLoading" class="ctms-table">
|
||||
<el-table-column prop="action" :label="TEXT.modules.fileVersionManagement.labels.auditAction" width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="font-medium">{{ row.action }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="detail" :label="TEXT.modules.fileVersionManagement.labels.auditDetail" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column prop="operator_role" :label="TEXT.common.labels.role" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="plain" size="small">{{ row.operator_role }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" :label="TEXT.modules.fileVersionManagement.labels.auditTime" width="180">
|
||||
<el-table-column label="抄送时间" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="text-secondary">{{ formatDate(row.created_at) }}</span>
|
||||
</template>
|
||||
@@ -170,6 +126,7 @@
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="uploadVisible" :title="TEXT.modules.fileVersionManagement.dialog.uploadTitle" width="520px" destroy-on-close class="ctms-dialog">
|
||||
@@ -177,6 +134,15 @@
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.versionNo" prop="version_no">
|
||||
<el-input v-model="uploadForm.version_no" :placeholder="TEXT.modules.fileVersionManagement.placeholders.versionNo" />
|
||||
</el-form-item>
|
||||
<el-form-item label="版本日期" prop="version_date">
|
||||
<el-date-picker
|
||||
v-model="uploadForm.version_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.changeSummary">
|
||||
<el-input v-model="uploadForm.change_summary" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
@@ -204,49 +170,44 @@
|
||||
<el-form :model="distributeForm" label-width="100px" class="ctms-form">
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.targetList">
|
||||
<div class="target-list w-full">
|
||||
<div class="target-row" v-for="(item, index) in distributeForm.targets" :key="item.uid">
|
||||
<el-select
|
||||
v-model="item.target_type"
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
style="width: 120px"
|
||||
@change="onTargetTypeChange(index)"
|
||||
>
|
||||
<div class="target-toolbar">
|
||||
<el-select v-model="distributeForm.target_type" class="target-type-preset" :placeholder="TEXT.common.labels.all" @change="onTargetTypeChange">
|
||||
<el-option :label="TEXT.common.labels.all" value="ALL" />
|
||||
<el-option :label="TEXT.enums.distributionTargetType.ROLE" value="ROLE" />
|
||||
<el-option :label="TEXT.enums.distributionTargetType.USER" value="USER" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-if="item.target_type === 'ROLE'"
|
||||
v-model="item.target_id"
|
||||
filterable
|
||||
clearable
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
class="target-select"
|
||||
>
|
||||
<el-option v-for="role in roleOptions" :key="role.value" :label="role.label" :value="role.value" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-else
|
||||
v-model="item.target_id"
|
||||
filterable
|
||||
clearable
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
class="target-select"
|
||||
>
|
||||
<el-option v-for="member in memberOptions" :key="member.value" :label="member.label" :value="member.value" />
|
||||
</el-select>
|
||||
<el-button link type="danger" @click="removeTarget(index)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button type="primary" plain @click="addTarget" class="w-full dashed-button">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.modules.fileVersionManagement.actions.addTarget }}
|
||||
</el-button>
|
||||
<div class="target-row">
|
||||
<template v-if="distributeForm.target_type === 'ROLE'">
|
||||
<el-select
|
||||
v-model="distributeForm.role_ids"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
class="target-select"
|
||||
>
|
||||
<el-option v-for="role in roleOptions" :key="role.value" :label="role.label" :value="role.value" />
|
||||
</el-select>
|
||||
</template>
|
||||
<template v-else-if="distributeForm.target_type === 'USER'">
|
||||
<el-select
|
||||
v-model="distributeForm.user_ids"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
:placeholder="TEXT.common.placeholders.select"
|
||||
class="target-select"
|
||||
>
|
||||
<el-option v-for="member in memberOptions" :key="member.value" :label="member.label" :value="member.value" />
|
||||
</el-select>
|
||||
</template>
|
||||
<div v-else class="target-all">
|
||||
<el-tag effect="plain" type="info">{{ TEXT.common.labels.all }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.dueAt">
|
||||
<el-date-picker v-model="distributeForm.due_at" type="datetime" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="distributeVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
@@ -254,79 +215,40 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="submitVisible" :title="TEXT.modules.fileVersionManagement.messages.submitTitle" width="480px" destroy-on-close class="ctms-dialog">
|
||||
<el-form :model="submitForm" label-width="100px" class="ctms-form">
|
||||
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.workflowTemplate">
|
||||
<el-select
|
||||
v-model="submitForm.template_id"
|
||||
filterable
|
||||
clearable
|
||||
:placeholder="TEXT.modules.fileVersionManagement.placeholders.workflowTemplate"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="item in workflowTemplates" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="submitVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="confirmSubmit">{{ TEXT.common.actions.confirm }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, FormInstance, FormRules } from "element-plus";
|
||||
import { ArrowLeft, Upload, Plus, Download, Check, Close, Link, Share, Delete } from "@element-plus/icons-vue";
|
||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
||||
import { ArrowLeft, Upload, Download, Share } from "@element-plus/icons-vue";
|
||||
import {
|
||||
approveVersion,
|
||||
createAcknowledgement,
|
||||
createDistributions,
|
||||
deleteDocumentVersion,
|
||||
downloadDocumentVersion,
|
||||
fetchDocumentDetail,
|
||||
listDistributions,
|
||||
listWorkflowTemplates,
|
||||
makeVersionEffective,
|
||||
rejectVersion,
|
||||
submitVersion,
|
||||
uploadDocumentVersion,
|
||||
} from "../../api/documents";
|
||||
import { fetchAuditLogs } from "../../api/auditLogs";
|
||||
import { listMembers } from "../../api/members";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { fetchUsers } from "../../api/users";
|
||||
import type { Distribution, DocumentDetail, DocumentVersion, WorkflowTemplate } from "../../types/documents";
|
||||
import type { Distribution, DocumentDetail, DocumentVersion } from "../../types/documents";
|
||||
import type { Site, StudyMember } from "../../types/api";
|
||||
import type { UserInfo } from "../../types/api";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { TEXT } from "../../locales";
|
||||
import { displayEnum, displayText, getUserDisplayName } from "../../utils/display";
|
||||
|
||||
const getVersionStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case "APPROVED":
|
||||
return "success";
|
||||
case "DRAFT":
|
||||
return "info";
|
||||
case "SUBMITTED":
|
||||
return "warning";
|
||||
case "REJECTED":
|
||||
return "danger";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const documentId = computed(() => route.params.id as string);
|
||||
|
||||
const loading = ref(false);
|
||||
const versionLoading = ref(false);
|
||||
const distributionLoading = ref(false);
|
||||
const auditLoading = ref(false);
|
||||
const detail = reactive<DocumentDetail>({
|
||||
id: "",
|
||||
trial_id: "",
|
||||
@@ -344,11 +266,9 @@ const detail = reactive<DocumentDetail>({
|
||||
|
||||
const activeTab = ref("versions");
|
||||
const distributions = ref<Distribution[]>([]);
|
||||
const auditLogs = ref<any[]>([]);
|
||||
const users = ref<UserInfo[]>([]);
|
||||
const sites = ref<Site[]>([]);
|
||||
const members = ref<StudyMember[]>([]);
|
||||
const workflowTemplates = ref<WorkflowTemplate[]>([]);
|
||||
|
||||
const siteActiveMap = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
@@ -358,29 +278,57 @@ const siteActiveMap = computed(() => {
|
||||
return map;
|
||||
});
|
||||
const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false);
|
||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
|
||||
const userMap = computed(() =>
|
||||
users.value.reduce<Record<string, string>>((acc, user) => {
|
||||
acc[user.id] = getUserDisplayName(user) || user.email || user.username || user.id;
|
||||
const memberUserMap = computed(() =>
|
||||
members.value.reduce<Record<string, string>>((acc, member) => {
|
||||
const user = (member as any).user;
|
||||
const name = getUserDisplayName(user) || user?.email || user?.username || member.user_id;
|
||||
acc[member.user_id] = name;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
const userMap = computed(() => {
|
||||
const map = { ...memberUserMap.value };
|
||||
users.value.forEach((user) => {
|
||||
map[user.id] = getUserDisplayName(user) || user.email || user.username || user.id;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
const displayCreator = (userId?: string | null) => {
|
||||
if (!userId) return TEXT.common.fallback;
|
||||
return userMap.value[userId] || userId;
|
||||
};
|
||||
|
||||
const displayParentVersion = (parentId?: string | null) => {
|
||||
if (!parentId) return TEXT.common.fallback;
|
||||
const hit = detail.version_timeline?.find((item: any) => item.id === parentId);
|
||||
return hit?.version_no ? `V${hit.version_no}` : TEXT.common.fallback;
|
||||
};
|
||||
|
||||
const displaySite = (siteId?: string | null) => {
|
||||
if (!siteId) return TEXT.enums.scopeType.GLOBAL;
|
||||
return sites.value.find((s) => s.id === siteId)?.name || siteId;
|
||||
};
|
||||
|
||||
const currentEffectiveVersion = computed(() => {
|
||||
if (!detail.current_effective_version_id) return null;
|
||||
return detail.version_timeline?.find((item: any) => item.id === detail.current_effective_version_id) || null;
|
||||
});
|
||||
|
||||
const distributionVersionId = computed(() => {
|
||||
if (detail.current_effective_version_id) return detail.current_effective_version_id;
|
||||
return detail.version_timeline?.[0]?.id || null;
|
||||
});
|
||||
|
||||
const uploadVisible = ref(false);
|
||||
const uploading = ref(false);
|
||||
const uploadFormRef = ref<FormInstance>();
|
||||
const uploadForm = reactive({
|
||||
version_no: "",
|
||||
version_date: "",
|
||||
change_summary: "",
|
||||
parent_version_id: "",
|
||||
});
|
||||
@@ -388,19 +336,12 @@ const uploadFile = ref<File | null>(null);
|
||||
|
||||
const distributeVisible = ref(false);
|
||||
const distributing = ref(false);
|
||||
const targetSeed = ref(0);
|
||||
const distributeForm = reactive({
|
||||
targets: [{ uid: 0, target_type: "ROLE", target_id: "" }],
|
||||
due_at: "",
|
||||
target_type: "ALL" as "ALL" | "ROLE" | "USER",
|
||||
role_ids: [] as string[],
|
||||
user_ids: [] as string[],
|
||||
});
|
||||
|
||||
const submitVisible = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const submitForm = reactive({
|
||||
template_id: "",
|
||||
});
|
||||
const submitTarget = ref<DocumentVersion | null>(null);
|
||||
|
||||
const uploadRules: FormRules = {
|
||||
version_no: [
|
||||
{ required: true, message: TEXT.common.messages.required, trigger: "blur" },
|
||||
@@ -412,6 +353,7 @@ const uploadRules: FormRules = {
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
version_date: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
|
||||
};
|
||||
|
||||
const roleOptions = computed(() => {
|
||||
@@ -447,12 +389,6 @@ const displayTarget = (row: Distribution) => {
|
||||
return row.target_id;
|
||||
};
|
||||
|
||||
const createTarget = () => ({
|
||||
uid: ++targetSeed.value,
|
||||
target_type: "ROLE",
|
||||
target_id: "",
|
||||
});
|
||||
|
||||
const canAction = (action: string) => {
|
||||
if (isReadOnly.value && action !== "view") return false;
|
||||
const list = detail.can_actions || [];
|
||||
@@ -487,6 +423,7 @@ const loadSites = async () => {
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
if (!isAdmin.value) return;
|
||||
try {
|
||||
const { data } = await fetchUsers({ limit: 500 });
|
||||
users.value = (data as any).items || data || [];
|
||||
@@ -521,13 +458,13 @@ const loadDetail = async () => {
|
||||
};
|
||||
|
||||
const loadDistributions = async () => {
|
||||
if (!detail.current_effective_version_id) {
|
||||
if (!distributionVersionId.value) {
|
||||
distributions.value = [];
|
||||
return;
|
||||
}
|
||||
distributionLoading.value = true;
|
||||
try {
|
||||
const { data } = await listDistributions(detail.current_effective_version_id);
|
||||
const { data } = await listDistributions(distributionVersionId.value);
|
||||
distributions.value = data || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
@@ -536,44 +473,15 @@ const loadDistributions = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadAuditLogs = async () => {
|
||||
if (!detail.trial_id) return;
|
||||
auditLoading.value = true;
|
||||
try {
|
||||
const { data } = await fetchAuditLogs(detail.trial_id, {
|
||||
entity_type: "DOCUMENT",
|
||||
entity_id: documentId.value,
|
||||
limit: 20,
|
||||
});
|
||||
auditLogs.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
auditLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadWorkflowTemplates = async () => {
|
||||
try {
|
||||
const params = detail.trial_id ? { trial_id: detail.trial_id } : undefined;
|
||||
const { data } = await listWorkflowTemplates(params);
|
||||
workflowTemplates.value = data || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = async (name: string) => {
|
||||
if (name === "distributions") {
|
||||
await loadDistributions();
|
||||
}
|
||||
if (name === "audit") {
|
||||
await loadAuditLogs();
|
||||
}
|
||||
};
|
||||
|
||||
const openUpload = () => {
|
||||
uploadForm.version_no = "";
|
||||
uploadForm.version_date = "";
|
||||
uploadForm.change_summary = "";
|
||||
uploadForm.parent_version_id = "";
|
||||
uploadFile.value = null;
|
||||
@@ -600,6 +508,9 @@ const submitUpload = async () => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("version_no", uploadForm.version_no);
|
||||
if (uploadForm.version_date) {
|
||||
formData.append("version_date", uploadForm.version_date);
|
||||
}
|
||||
if (uploadForm.change_summary) {
|
||||
formData.append("change_summary", uploadForm.change_summary);
|
||||
}
|
||||
@@ -619,73 +530,6 @@ const submitUpload = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const openSubmit = async (version: DocumentVersion) => {
|
||||
submitTarget.value = version;
|
||||
submitForm.template_id = "";
|
||||
submitVisible.value = true;
|
||||
if (!workflowTemplates.value.length) {
|
||||
await loadWorkflowTemplates();
|
||||
}
|
||||
};
|
||||
|
||||
const confirmSubmit = async () => {
|
||||
if (!submitTarget.value) return;
|
||||
if (!submitForm.template_id) {
|
||||
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.workflowTemplateRequired);
|
||||
return;
|
||||
}
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
await submitVersion(submitTarget.value.id, { template_id: submitForm.template_id, comment: "" });
|
||||
submitVisible.value = false;
|
||||
await loadDetail();
|
||||
ElMessage.success(TEXT.modules.fileVersionManagement.messages.submitSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const approve = async (version: DocumentVersion) => {
|
||||
versionLoading.value = true;
|
||||
try {
|
||||
await approveVersion(version.id, { comment: "" });
|
||||
await loadDetail();
|
||||
ElMessage.success(TEXT.modules.fileVersionManagement.messages.approveSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
versionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const reject = async (version: DocumentVersion) => {
|
||||
versionLoading.value = true;
|
||||
try {
|
||||
await rejectVersion(version.id, { comment: "" });
|
||||
await loadDetail();
|
||||
ElMessage.success(TEXT.modules.fileVersionManagement.messages.rejectSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
versionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const makeEffective = async (version: DocumentVersion) => {
|
||||
versionLoading.value = true;
|
||||
try {
|
||||
await makeVersionEffective(version.id);
|
||||
await loadDetail();
|
||||
ElMessage.success(TEXT.modules.fileVersionManagement.messages.effectiveSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
versionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getFilename = (header?: string | null) => {
|
||||
if (!header) return null;
|
||||
const match = /filename\*=UTF-8''([^;]+)|filename="?([^\";]+)"?/i.exec(header);
|
||||
@@ -714,47 +558,72 @@ const downloadVersion = async (version: DocumentVersion) => {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteVersion = async (version: DocumentVersion) => {
|
||||
if (!version?.id) return;
|
||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteDocumentVersion(version.id);
|
||||
ElMessage.success(TEXT.common.messages.deleteSuccess);
|
||||
await loadDetail();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const openDistribute = () => {
|
||||
distributeForm.targets = [createTarget()];
|
||||
distributeForm.due_at = "";
|
||||
distributeForm.target_type = "ALL";
|
||||
distributeForm.role_ids = [];
|
||||
distributeForm.user_ids = [];
|
||||
distributeVisible.value = true;
|
||||
if (!members.value.length) {
|
||||
loadMembers();
|
||||
}
|
||||
};
|
||||
|
||||
const addTarget = () => {
|
||||
distributeForm.targets.push(createTarget());
|
||||
};
|
||||
|
||||
const removeTarget = (index: number) => {
|
||||
distributeForm.targets.splice(index, 1);
|
||||
};
|
||||
|
||||
const onTargetTypeChange = (index: number) => {
|
||||
const target = distributeForm.targets[index];
|
||||
if (!target) return;
|
||||
target.target_id = "";
|
||||
const onTargetTypeChange = () => {
|
||||
distributeForm.role_ids = [];
|
||||
distributeForm.user_ids = [];
|
||||
};
|
||||
|
||||
const submitDistribute = async () => {
|
||||
if (!detail.current_effective_version_id) {
|
||||
if (!distributionVersionId.value) {
|
||||
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.noEffective);
|
||||
return;
|
||||
}
|
||||
if (distributeForm.targets.some((t) => !t.target_id)) {
|
||||
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.missingTarget);
|
||||
return;
|
||||
}
|
||||
distributing.value = true;
|
||||
try {
|
||||
const targets = distributeForm.targets.map((t) => ({
|
||||
target_type: t.target_type,
|
||||
target_id: t.target_id,
|
||||
}));
|
||||
await createDistributions(detail.current_effective_version_id, {
|
||||
let targets: { target_type: string; target_id: string }[] = [];
|
||||
if (distributeForm.target_type === "ALL") {
|
||||
targets = memberOptions.value.map((m) => ({
|
||||
target_type: "USER",
|
||||
target_id: m.value,
|
||||
}));
|
||||
} else if (distributeForm.target_type === "ROLE") {
|
||||
if (!distributeForm.role_ids.length) {
|
||||
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.missingTarget);
|
||||
return;
|
||||
}
|
||||
targets = distributeForm.role_ids.map((role) => ({
|
||||
target_type: "ROLE",
|
||||
target_id: role,
|
||||
}));
|
||||
} else if (distributeForm.target_type === "USER") {
|
||||
if (!distributeForm.user_ids.length) {
|
||||
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.missingTarget);
|
||||
return;
|
||||
}
|
||||
targets = distributeForm.user_ids.map((user) => ({
|
||||
target_type: "USER",
|
||||
target_id: user,
|
||||
}));
|
||||
}
|
||||
if (!targets.length) {
|
||||
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.missingTarget);
|
||||
return;
|
||||
}
|
||||
await createDistributions(distributionVersionId.value, {
|
||||
targets,
|
||||
due_at: distributeForm.due_at || undefined,
|
||||
});
|
||||
distributeVisible.value = false;
|
||||
await loadDistributions();
|
||||
@@ -766,16 +635,6 @@ const submitDistribute = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const ack = async (distribution: Distribution, ackType: string) => {
|
||||
try {
|
||||
await createAcknowledgement(distribution.id, { ack_type: ackType });
|
||||
await loadDistributions();
|
||||
ElMessage.success(TEXT.modules.fileVersionManagement.messages.ackSuccess);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (detail.trial_id) {
|
||||
router.push(`/trial/${detail.trial_id}/documents`);
|
||||
@@ -789,6 +648,12 @@ const formatDate = (value?: string | null) => {
|
||||
return value.replace("T", " ").replace("Z", "").split(".")[0];
|
||||
};
|
||||
|
||||
const formatDateOnly = (value?: string | null) => {
|
||||
const formatted = formatDate(value);
|
||||
if (formatted === TEXT.common.fallback) return formatted;
|
||||
return formatted.split(" ")[0];
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDetail();
|
||||
await loadDistributions();
|
||||
@@ -846,6 +711,36 @@ watch(
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.target-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.target-type-preset {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.target-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.target-select {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.target-all {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -1,46 +1,34 @@
|
||||
<template>
|
||||
<div class="ctms-page">
|
||||
<div class="ctms-page-header">
|
||||
<div>
|
||||
<h1 class="ctms-page-title">{{ TEXT.modules.fileVersionManagement.title }}</h1>
|
||||
</div>
|
||||
<div class="ctms-page-actions">
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.modules.fileVersionManagement.newDocument }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="ctms-filter-card">
|
||||
<div class="ctms-filter-row">
|
||||
<div class="ctms-filter-item">
|
||||
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.scope }}</span>
|
||||
<el-select v-model="filters.scope_type" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable>
|
||||
<el-option v-for="item in scopeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctms-filter-item">
|
||||
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.site }}</span>
|
||||
<el-select v-model="filters.site_id" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable filterable>
|
||||
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctms-filter-item">
|
||||
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.docType }}</span>
|
||||
<el-select v-model="filters.doc_type" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable>
|
||||
<el-option v-for="item in docTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctms-filter-spacer"></div>
|
||||
<div class="ctms-filter-actions">
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</div>
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.scope_type" :placeholder="TEXT.modules.fileVersionManagement.filters.scope" clearable class="filter-select-comp">
|
||||
<el-option v-for="item in scopeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.site_id" :placeholder="TEXT.modules.fileVersionManagement.filters.site" clearable filterable class="filter-select-comp">
|
||||
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select v-model="filters.doc_type" :placeholder="TEXT.modules.fileVersionManagement.filters.docType" clearable class="filter-select-comp">
|
||||
<el-option v-for="item in docTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-item-form">
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="openCreate" class="header-action-btn">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.modules.fileVersionManagement.newDocument }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="ctms-table-card">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
@@ -86,7 +74,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
|
||||
<el-button v-if="isAdmin" link type="danger" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -137,6 +125,7 @@ import { Plus } from "@element-plus/icons-vue";
|
||||
import { fetchDocuments, createDocument, deleteDocument } from "../../api/documents";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import type { DocumentSummary } from "../../types/documents";
|
||||
import type { Site } from "../../types/api";
|
||||
import { displayEnum, displayText } from "../../utils/display";
|
||||
@@ -145,6 +134,7 @@ import { TEXT } from "../../locales";
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const auth = useAuthStore();
|
||||
const loading = ref(false);
|
||||
const items = ref<DocumentSummary[]>([]);
|
||||
const sites = ref<Site[]>([]);
|
||||
@@ -178,11 +168,23 @@ const siteActiveMap = computed(() => {
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const isAdmin = computed(() => {
|
||||
const role = auth.user?.role;
|
||||
return role === "ADMIN";
|
||||
});
|
||||
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 filteredItems = computed(() =>
|
||||
items.value.filter((item) => {
|
||||
if (filters.scope_type && item?.scope_type !== filters.scope_type) return false;
|
||||
if (filters.site_id && item?.site_id !== filters.site_id) return false;
|
||||
if (filters.doc_type && item?.doc_type !== filters.doc_type) return false;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
const sortedItems = computed(() =>
|
||||
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
|
||||
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
|
||||
);
|
||||
|
||||
const createForm = reactive({
|
||||
@@ -240,12 +242,7 @@ const load = async () => {
|
||||
if (!trialId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await fetchDocuments({
|
||||
trial_id: trialId.value,
|
||||
scope_type: filters.scope_type || undefined,
|
||||
site_id: filters.site_id || undefined,
|
||||
doc_type: filters.doc_type || undefined,
|
||||
});
|
||||
const { data } = await fetchDocuments({ trial_id: trialId.value });
|
||||
items.value = data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
@@ -258,7 +255,6 @@ const resetFilters = () => {
|
||||
filters.scope_type = "";
|
||||
filters.site_id = "";
|
||||
filters.doc_type = "";
|
||||
load();
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
@@ -343,24 +339,43 @@ watch(
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-secondary {
|
||||
color: var(--ctms-text-secondary);
|
||||
.ctms-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--ctms-primary);
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
.filter-item-form {
|
||||
margin-bottom: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.filter-select-comp {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -89,6 +89,11 @@ const load = async () => {
|
||||
try {
|
||||
const { data } = await getDrugShipment(studyId, shipmentId);
|
||||
Object.assign(detail, data);
|
||||
|
||||
// 同步中心名称到面包屑
|
||||
if (detail.site_name) {
|
||||
study.setViewContext({ siteName: detail.site_name });
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.feeContracts.detailTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.feeContracts.detailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :disabled="!canWrite || isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
|
||||
<el-icon class="el-icon--left"><Edit /></el-icon>
|
||||
@@ -14,7 +9,6 @@
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
@@ -201,6 +195,11 @@ const load = async () => {
|
||||
const { data } = await getContractFee(contractId);
|
||||
Object.assign(detail, data?.data || {});
|
||||
if (!Array.isArray(detail.payments)) detail.payments = [];
|
||||
|
||||
// 同步中心名称到面包屑
|
||||
if (centerName.value) {
|
||||
study.setViewContext({ siteName: centerName.value });
|
||||
}
|
||||
} catch (e: any) {
|
||||
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
|
||||
} finally {
|
||||
@@ -233,7 +232,7 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.feeContracts.title }}</h1>
|
||||
</div>
|
||||
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.modules.feeContracts.newTitle }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="overview">
|
||||
<el-row :gutter="20">
|
||||
@@ -55,34 +46,39 @@
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-select v-model="filters.centerId" clearable :placeholder="TEXT.common.fields.site" class="filter-select">
|
||||
<template #prefix>
|
||||
<el-icon><Location /></el-icon>
|
||||
</template>
|
||||
<el-option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:label="site.name || TEXT.common.fallback"
|
||||
:value="site.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-input v-model="filters.q" :placeholder="TEXT.common.placeholders.keyword" clearable class="filter-input">
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-actions">
|
||||
<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 shadow="never" class="main-content-card" v-if="!errorMessage && !loading">
|
||||
<div class="filter-container">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-select v-model="filters.centerId" clearable :placeholder="TEXT.common.fields.site" class="filter-select">
|
||||
<template #prefix>
|
||||
<el-icon><Location /></el-icon>
|
||||
</template>
|
||||
<el-option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:label="site.name || TEXT.common.fallback"
|
||||
:value="site.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-input v-model="filters.q" :placeholder="TEXT.common.placeholders.keyword" clearable class="filter-input">
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-actions">
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.modules.feeContracts.newTitle }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
@@ -92,7 +88,6 @@
|
||||
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<el-card v-else class="table-card" shadow="never">
|
||||
<el-table
|
||||
:data="sortedContracts"
|
||||
style="width: 100%"
|
||||
@@ -100,26 +95,26 @@
|
||||
:row-class-name="contractRowClass"
|
||||
:header-cell-style="{ background: '#f8f9fb' }"
|
||||
>
|
||||
<el-table-column prop="center_name" :label="TEXT.common.fields.site" min-width="180">
|
||||
<el-table-column prop="center_name" :label="TEXT.common.fields.site" min-width="160">
|
||||
<template #default="scope">
|
||||
<div class="site-cell">
|
||||
<span class="site-name">{{ scope.row.center_name || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.feeContracts.contractAmount" width="160" align="right">
|
||||
<el-table-column :label="TEXT.modules.feeContracts.contractAmount" width="140" align="right">
|
||||
<template #default="scope">
|
||||
<span class="amount-text">{{ formatAmountWan(scope.row.contract_amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.feeContracts.caseProgress" min-width="150" align="center">
|
||||
<el-table-column :label="TEXT.modules.feeContracts.caseProgress" min-width="140" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag effect="plain" type="info">
|
||||
{{ displayCases(scope.row.contract_cases, scope.row.actual_cases) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.feeContracts.paidSummary" min-width="180">
|
||||
<el-table-column :label="TEXT.modules.feeContracts.paidSummary" min-width="200">
|
||||
<template #default="scope">
|
||||
<div class="summary-line">
|
||||
<span>{{ TEXT.modules.feeContracts.paidTotal }}</span>
|
||||
@@ -132,7 +127,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.feeContracts.verifySummary" min-width="180">
|
||||
<el-table-column :label="TEXT.modules.feeContracts.verifySummary" min-width="200">
|
||||
<template #default="scope">
|
||||
<div class="summary-line">
|
||||
<span>{{ TEXT.modules.feeContracts.verifiedTotal }}</span>
|
||||
@@ -145,7 +140,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.feeContracts.recentDates" min-width="200">
|
||||
<el-table-column :label="TEXT.modules.feeContracts.recentDates" min-width="180">
|
||||
<template #default="scope">
|
||||
<div class="date-line">
|
||||
<span class="date-label">{{ TEXT.modules.feeContracts.lastPaid }}</span>
|
||||
@@ -157,12 +152,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.remark" min-width="180" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
{{ scope.row.remark || TEXT.common.fallback }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="100">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
@@ -182,7 +172,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Coin, Wallet, CircleCheck, CircleClose, Plus, Location, Search } from "@element-plus/icons-vue";
|
||||
@@ -214,14 +204,26 @@ const siteActiveMap = computed(() => {
|
||||
return map;
|
||||
});
|
||||
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||
const filteredContracts = computed(() => {
|
||||
const keyword = filters.q.trim().toLowerCase();
|
||||
return contracts.value.filter((item) => {
|
||||
if (filters.centerId && item?.center_id !== filters.centerId) return false;
|
||||
if (keyword) {
|
||||
const fields = [item?.center_name, item?.contract_no, item?.contract_name];
|
||||
const matched = fields.some((field) => String(field || "").toLowerCase().includes(keyword));
|
||||
if (!matched) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
const sortedContracts = computed(() =>
|
||||
[...contracts.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
|
||||
[...filteredContracts.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: "",
|
||||
centerId: study.currentSite?.id || "",
|
||||
q: "",
|
||||
});
|
||||
|
||||
@@ -231,7 +233,7 @@ const toNumber = (value: any) => {
|
||||
};
|
||||
|
||||
const overview = computed(() => {
|
||||
const totals = contracts.value.reduce(
|
||||
const totals = filteredContracts.value.reduce(
|
||||
(acc, item) => {
|
||||
acc.totalContractAmount += toNumber(item.contract_amount);
|
||||
acc.totalPaidAmount += toNumber(item.paid_total);
|
||||
@@ -248,7 +250,7 @@ const overview = computed(() => {
|
||||
);
|
||||
return {
|
||||
...totals,
|
||||
centerCount: contracts.value.length,
|
||||
centerCount: filteredContracts.value.length,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -269,11 +271,7 @@ const load = async () => {
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const { data } = await listContractFees({
|
||||
projectId,
|
||||
centerId: filters.centerId || undefined,
|
||||
q: filters.q || undefined,
|
||||
});
|
||||
const { data } = await listContractFees({ projectId });
|
||||
contracts.value = data?.data || [];
|
||||
} catch (e: any) {
|
||||
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
|
||||
@@ -285,7 +283,6 @@ const load = async () => {
|
||||
const resetFilters = () => {
|
||||
filters.centerId = "";
|
||||
filters.q = "";
|
||||
load();
|
||||
};
|
||||
|
||||
const goNew = () => router.push("/fees/contracts/new");
|
||||
@@ -327,6 +324,19 @@ const formatAmountWan = (value: any) => {
|
||||
return (numberValue / 10000).toFixed(2);
|
||||
};
|
||||
|
||||
watch(() => filters.centerId, (val: string) => {
|
||||
if (val) {
|
||||
const matched = sites.value.find(s => s.id === val);
|
||||
if (matched) study.setCurrentSite(matched);
|
||||
} else {
|
||||
study.setCurrentSite(null);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => study.currentSite, (newSite: any) => {
|
||||
filters.centerId = newSite?.id || "";
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
@@ -337,58 +347,21 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.overview {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: white;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
@@ -398,14 +371,17 @@ onMounted(async () => {
|
||||
|
||||
.filter-select,
|
||||
.filter-input {
|
||||
width: 240px;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
margin-left: auto !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
@@ -424,22 +400,32 @@ onMounted(async () => {
|
||||
|
||||
.summary-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--ctms-text-regular);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-line:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.summary-line span:first-child {
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.amount-highlight {
|
||||
font-weight: 600;
|
||||
font-family: var(--el-font-family);
|
||||
}
|
||||
|
||||
.summary-line .amount-highlight,
|
||||
.summary-line .amount-muted {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.amount-highlight.success { color: var(--el-color-success); }
|
||||
.amount-highlight.warning { color: var(--el-color-warning); }
|
||||
.amount-highlight.info { color: var(--el-color-info); }
|
||||
@@ -447,9 +433,11 @@ onMounted(async () => {
|
||||
|
||||
.date-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
margin-bottom: 2px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-label {
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.feeSpecials.detailTitle }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.feeSpecials.detailSubtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<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>
|
||||
<el-button @click="goBack" class="header-action-btn">
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<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>
|
||||
<el-button @click="goBack" class="header-action-btn">
|
||||
{{ TEXT.common.actions.back }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
@@ -178,6 +172,11 @@ const load = async () => {
|
||||
try {
|
||||
const { data } = await getSpecialExpense(expenseId);
|
||||
Object.assign(detail, data?.data || {});
|
||||
|
||||
// 同步中心名称到面包屑
|
||||
if (centerName.value) {
|
||||
study.setViewContext({ siteName: centerName.value });
|
||||
}
|
||||
} catch (e: any) {
|
||||
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
|
||||
} finally {
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.feeSpecials.title }}</h1>
|
||||
</div>
|
||||
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.modules.feeSpecials.newTitle }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="overview">
|
||||
<div class="overview-header">
|
||||
<span class="overview-title">{{ TEXT.modules.feeSpecials.overviewTitle }}</span>
|
||||
<el-tag effect="plain" type="info" size="small">{{ TEXT.modules.feeSpecials.recordCount }} {{ overview.recordCount }}</el-tag>
|
||||
</div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<KpiCard
|
||||
@@ -58,56 +45,51 @@
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-select v-model="filters.centerId" clearable :placeholder="TEXT.common.fields.site" class="filter-select">
|
||||
<template #prefix>
|
||||
<el-icon><Location /></el-icon>
|
||||
</template>
|
||||
<el-option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:label="site.name || TEXT.common.fallback"
|
||||
:value="site.id"
|
||||
<el-card shadow="never" class="main-content-card" v-if="!errorMessage && !loading">
|
||||
<div class="filter-container">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-select v-model="filters.centerId" clearable :placeholder="TEXT.common.fields.site" class="filter-select">
|
||||
<template #prefix>
|
||||
<el-icon><Location /></el-icon>
|
||||
</template>
|
||||
<el-option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:label="site.name || TEXT.common.fallback"
|
||||
:value="site.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-select v-model="filters.category" clearable :placeholder="TEXT.common.fields.category" class="filter-select">
|
||||
<template #prefix>
|
||||
<el-icon><Menu /></el-icon>
|
||||
</template>
|
||||
<el-option v-for="option in categoryOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-date-picker
|
||||
v-model="filters.dateRange"
|
||||
type="daterange"
|
||||
unlink-panels
|
||||
value-format="YYYY-MM-DD"
|
||||
:start-placeholder="TEXT.common.placeholders.startDate"
|
||||
:end-placeholder="TEXT.common.placeholders.endDate"
|
||||
class="filter-date"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-select v-model="filters.category" clearable :placeholder="TEXT.common.fields.category" class="filter-select">
|
||||
<template #prefix>
|
||||
<el-icon><Menu /></el-icon>
|
||||
</template>
|
||||
<el-option v-for="option in categoryOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-date-picker
|
||||
v-model="filters.dateRange"
|
||||
type="daterange"
|
||||
unlink-panels
|
||||
value-format="YYYY-MM-DD"
|
||||
:start-placeholder="TEXT.common.placeholders.startDate"
|
||||
:end-placeholder="TEXT.common.placeholders.endDate"
|
||||
class="filter-date"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-actions">
|
||||
<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>
|
||||
|
||||
<StateError v-if="errorMessage" :description="errorMessage">
|
||||
<template #action>
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
|
||||
</template>
|
||||
</StateError>
|
||||
|
||||
<StateLoading v-else-if="loading" :rows="6" />
|
||||
|
||||
<el-card v-else class="table-card" shadow="never">
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-actions">
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.modules.feeSpecials.newTitle }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-table
|
||||
:data="sortedExpenses"
|
||||
style="width: 100%"
|
||||
@@ -132,7 +114,7 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.amount" width="140" align="right">
|
||||
<el-table-column :label="TEXT.common.fields.amount" width="110" align="right">
|
||||
<template #default="scope">
|
||||
<span class="amount-text">{{ formatAmount(scope.row.amount) }}</span>
|
||||
</template>
|
||||
@@ -142,7 +124,7 @@
|
||||
{{ scope.row.description || TEXT.common.fallback }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column width="140" align="center" :label="TEXT.common.labels.status">
|
||||
<el-table-column width="140" align="center" :label="TEXT.common.fields.status">
|
||||
<template #default="scope">
|
||||
<el-tooltip :content="scope.row.is_paid ? `${TEXT.modules.feeSpecials.isPaid} (${displayDate(scope.row.paid_date)})` : TEXT.modules.feeSpecials.unpaid" placement="top">
|
||||
<el-icon :class="scope.row.is_paid ? 'status-icon success' : 'status-icon muted'"><Wallet /></el-icon>
|
||||
@@ -180,7 +162,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Coin, Wallet, CircleCheck, Document, Plus, Location, Menu } from "@element-plus/icons-vue";
|
||||
@@ -214,19 +196,33 @@ const siteActiveMap = computed(() => {
|
||||
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 filteredExpenses = computed(() => {
|
||||
const [dateFrom, dateTo] = filters.dateRange || [];
|
||||
return expenses.value.filter((item) => {
|
||||
if (filters.centerId && item?.center_id !== filters.centerId) return false;
|
||||
if (filters.category && item?.category !== filters.category) return false;
|
||||
if (dateFrom || dateTo) {
|
||||
const value = item?.happen_date;
|
||||
if (!value) return false;
|
||||
if (dateFrom && value < dateFrom) return false;
|
||||
if (dateTo && value > dateTo) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
const sortedExpenses = computed(() =>
|
||||
[...expenses.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
|
||||
[...filteredExpenses.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
|
||||
);
|
||||
|
||||
const filters = reactive({
|
||||
centerId: "",
|
||||
centerId: study.currentSite?.id || "",
|
||||
category: "",
|
||||
dateRange: [] as string[],
|
||||
});
|
||||
|
||||
const categoryOptions = Object.keys(TEXT.enums.feeSpecialCategory).map((value) => ({
|
||||
value,
|
||||
label: TEXT.enums.feeSpecialCategory[value],
|
||||
label: (TEXT.enums.feeSpecialCategory as any)[value],
|
||||
}));
|
||||
|
||||
const toNumber = (value: any) => {
|
||||
@@ -235,7 +231,7 @@ const toNumber = (value: any) => {
|
||||
};
|
||||
|
||||
const overview = computed(() => {
|
||||
const totals = expenses.value.reduce(
|
||||
const totals = filteredExpenses.value.reduce(
|
||||
(acc, item) => {
|
||||
acc.totalAmount += toNumber(item.amount);
|
||||
if (item.is_paid) acc.paidAmount += toNumber(item.amount);
|
||||
@@ -252,7 +248,6 @@ const overview = computed(() => {
|
||||
);
|
||||
return {
|
||||
...totals,
|
||||
recordCount: expenses.value.length,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -273,14 +268,7 @@ const load = async () => {
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const [dateFrom, dateTo] = filters.dateRange || [];
|
||||
const { data } = await listSpecialExpenses({
|
||||
projectId,
|
||||
centerId: filters.centerId || undefined,
|
||||
category: filters.category || undefined,
|
||||
dateFrom: dateFrom || undefined,
|
||||
dateTo: dateTo || undefined,
|
||||
});
|
||||
const { data } = await listSpecialExpenses({ projectId });
|
||||
expenses.value = data?.data || [];
|
||||
} catch (e: any) {
|
||||
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
|
||||
@@ -293,7 +281,6 @@ const resetFilters = () => {
|
||||
filters.centerId = "";
|
||||
filters.category = "";
|
||||
filters.dateRange = [];
|
||||
load();
|
||||
};
|
||||
|
||||
const goNew = () => router.push("/fees/special/new");
|
||||
@@ -333,6 +320,19 @@ const getCategoryType = (category: string) => {
|
||||
return 'info';
|
||||
};
|
||||
|
||||
watch(() => filters.centerId, (val: string) => {
|
||||
if (val) {
|
||||
const matched = sites.value.find(s => s.id === val);
|
||||
if (matched) study.setCurrentSite(matched);
|
||||
} else {
|
||||
study.setCurrentSite(null);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => study.currentSite, (newSite: any) => {
|
||||
filters.centerId = newSite?.id || "";
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
@@ -343,71 +343,21 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.overview {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.overview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 4px 8px;
|
||||
}
|
||||
|
||||
.overview-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: white;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
@@ -416,18 +366,21 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 200px;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.filter-date {
|
||||
width: 280px;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
margin-left: auto !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
|
||||
@@ -1,47 +1,53 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.drugShipments.title }}</h1>
|
||||
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-select v-model="filters.center_id" clearable :placeholder="TEXT.common.fields.site" class="filter-select">
|
||||
<template #prefix>
|
||||
<el-icon><Location /></el-icon>
|
||||
</template>
|
||||
<el-option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:label="site.name || TEXT.common.fallback"
|
||||
:value="site.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-select v-model="filters.direction" clearable :placeholder="TEXT.common.fields.direction" class="filter-select">
|
||||
<template #prefix>
|
||||
<el-icon><Menu /></el-icon>
|
||||
</template>
|
||||
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
|
||||
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item">
|
||||
<el-select v-model="filters.status" clearable :placeholder="TEXT.common.fields.status" class="filter-select">
|
||||
<template #prefix>
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
</template>
|
||||
<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" />
|
||||
<el-option :label="TEXT.enums.shipmentStatus.RETURNED" value="RETURNED" />
|
||||
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-actions">
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="goNew" class="header-action-btn">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.modules.drugShipments.newTitle }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-button type="primary" @click="goNew">{{ TEXT.modules.drugShipments.newTitle }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card">
|
||||
<el-form :inline="true" :model="filters">
|
||||
<el-form-item :label="TEXT.common.fields.site">
|
||||
<el-select v-model="filters.center_id" :placeholder="TEXT.common.placeholders.select" clearable style="width: 200px">
|
||||
<el-option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:label="site.name || TEXT.common.fallback"
|
||||
:value="site.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.direction">
|
||||
<el-select v-model="filters.direction" :placeholder="TEXT.common.placeholders.select" clearable style="width: 160px">
|
||||
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
|
||||
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.status">
|
||||
<el-select v-model="filters.status" :placeholder="TEXT.common.placeholders.select" clearable style="width: 160px">
|
||||
<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" />
|
||||
<el-option :label="TEXT.enums.shipmentStatus.RETURNED" value="RETURNED" />
|
||||
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
|
||||
</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="sortedItems"
|
||||
v-loading="loading"
|
||||
@@ -52,9 +58,9 @@
|
||||
<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>
|
||||
<el-table-column prop="direction" :label="TEXT.common.fields.direction" width="100">
|
||||
<el-table-column prop="direction" :label="TEXT.common.fields.direction" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.direction === 'SEND' ? 'primary' : 'warning'" disable-transitions>
|
||||
<el-tag :class="['type-tag', scope.row.direction === 'SEND' ? 'type-send' : 'type-return']" effect="light">
|
||||
{{ displayEnum(TEXT.enums.shipmentDirection, scope.row.direction) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
@@ -71,9 +77,9 @@
|
||||
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" min-width="140">
|
||||
<template #default="scope">{{ scope.row.batch_no || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="140">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusType(scope.row.status)" disable-transitions>
|
||||
<el-tag :type="statusType(scope.row.status)" effect="plain" round size="small" class="status-tag">
|
||||
{{ displayEnum(TEXT.enums.shipmentStatus, scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
@@ -101,9 +107,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Location, Menu, CircleCheck, Plus } from "@element-plus/icons-vue";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { listDrugShipments, deleteDrugShipment } from "../../api/drugShipments";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
@@ -118,7 +125,7 @@ const items = ref<any[]>([]);
|
||||
const sites = ref<any[]>([]);
|
||||
const siteActiveMap = ref<Record<string, boolean>>({});
|
||||
const filters = reactive({
|
||||
center_id: "",
|
||||
center_id: study.currentSite?.id || "",
|
||||
direction: "",
|
||||
status: "",
|
||||
});
|
||||
@@ -144,12 +151,8 @@ const load = async () => {
|
||||
if (!studyId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await listDrugShipments(studyId, {
|
||||
center_id: filters.center_id || undefined,
|
||||
direction: filters.direction || undefined,
|
||||
status: filters.status || undefined,
|
||||
});
|
||||
items.value = Array.isArray(data) ? data : data.items || [];
|
||||
const { data } = await listDrugShipments(studyId, {});
|
||||
items.value = Array.isArray(data) ? data : data?.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
@@ -161,7 +164,6 @@ const resetFilters = () => {
|
||||
filters.center_id = "";
|
||||
filters.direction = "";
|
||||
filters.status = "";
|
||||
load();
|
||||
};
|
||||
|
||||
const goNew = () => router.push("/drug/shipments/new");
|
||||
@@ -173,8 +175,16 @@ const onRowClick = (row: any) => {
|
||||
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 filteredItems = computed(() =>
|
||||
items.value.filter((item) => {
|
||||
if (filters.center_id && item?.center_id !== filters.center_id) return false;
|
||||
if (filters.direction && item?.direction !== filters.direction) return false;
|
||||
if (filters.status && item?.status !== filters.status) return false;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
const sortedItems = computed(() =>
|
||||
[...items.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
|
||||
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
|
||||
);
|
||||
|
||||
const statusType = (status: string) => {
|
||||
@@ -212,6 +222,19 @@ const remove = async (row: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => filters.center_id, (val: string) => {
|
||||
if (val) {
|
||||
const matched = sites.value.find(s => s.id === val);
|
||||
if (matched) study.setCurrentSite(matched);
|
||||
} else {
|
||||
study.setCurrentSite(null);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => study.currentSite, (newSite: any) => {
|
||||
filters.center_id = newSite?.id || "";
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
@@ -222,30 +245,79 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
.filter-item {
|
||||
margin-bottom: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
margin-bottom: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.filter-actions :deep(.el-form-item__content) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-actions :deep(.el-button) {
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
padding: 0 18px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
.type-send {
|
||||
background-color: #fff7ed !important;
|
||||
color: #ea580c !important;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
.type-return {
|
||||
background-color: #fefce8 !important;
|
||||
color: #ca8a04 !important;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--ctms-text-placeholder);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.financeContracts.title }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.financeContracts.subtitle }}</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="goNew">{{ TEXT.modules.financeContracts.newTitle }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card">
|
||||
<el-form :inline="true" :model="filters">
|
||||
<el-form-item :label="TEXT.common.fields.site">
|
||||
<el-input v-model="filters.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.contractNo">
|
||||
<el-input v-model="filters.contract_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.contractNo" clearable />
|
||||
</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 class="filter-card" shadow="never">
|
||||
<div class="filter-row">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-input
|
||||
v-model="filters.site_name"
|
||||
:placeholder="TEXT.common.fields.site"
|
||||
clearable
|
||||
class="filter-input-comp"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-input
|
||||
v-model="filters.contract_no"
|
||||
:placeholder="TEXT.common.fields.contractNo"
|
||||
clearable
|
||||
class="filter-input-comp"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-item-form">
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="goNew" class="header-action-btn">
|
||||
{{ TEXT.modules.financeContracts.newTitle }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
@@ -99,8 +107,17 @@ const siteActiveMap = computed(() => {
|
||||
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 filteredItems = computed(() => {
|
||||
const siteKeyword = filters.site_name.trim().toLowerCase();
|
||||
const contractKeyword = filters.contract_no.trim().toLowerCase();
|
||||
return items.value.filter((item) => {
|
||||
if (siteKeyword && !String(item?.site_name || "").toLowerCase().includes(siteKeyword)) return false;
|
||||
if (contractKeyword && !String(item?.contract_no || "").toLowerCase().includes(contractKeyword)) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
const sortedItems = computed(() =>
|
||||
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
|
||||
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
|
||||
);
|
||||
|
||||
const loadSites = async () => {
|
||||
@@ -119,10 +136,7 @@ const load = async () => {
|
||||
if (!studyId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await listFinanceContracts(studyId, {
|
||||
site_name: filters.site_name || undefined,
|
||||
contract_no: filters.contract_no || undefined,
|
||||
});
|
||||
const { data } = await listFinanceContracts(studyId, {}) as any;
|
||||
items.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
@@ -134,7 +148,6 @@ const load = async () => {
|
||||
const resetFilters = () => {
|
||||
filters.site_name = "";
|
||||
filters.contract_no = "";
|
||||
load();
|
||||
};
|
||||
|
||||
const goNew = () => router.push("/finance/contracts/new");
|
||||
@@ -172,29 +185,55 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
.filter-card {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: white;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
.filter-item-form {
|
||||
margin-bottom: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
.filter-input-comp {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,31 +1,39 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.financeSpecials.title }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.financeSpecials.subtitle }}</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="goNew">{{ TEXT.modules.financeSpecials.newTitle }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card">
|
||||
<el-form :inline="true" :model="filters">
|
||||
<el-form-item :label="TEXT.common.fields.site">
|
||||
<el-input v-model="filters.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.type">
|
||||
<el-select v-model="filters.fee_type" :placeholder="TEXT.common.placeholders.select" clearable>
|
||||
<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" />
|
||||
<el-option :label="TEXT.enums.financeSpecialType.OTHER" value="OTHER" />
|
||||
</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 class="filter-card" shadow="never">
|
||||
<div class="filter-row">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-input
|
||||
v-model="filters.site_name"
|
||||
:placeholder="TEXT.common.fields.site"
|
||||
clearable
|
||||
class="filter-input-comp"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select
|
||||
v-model="filters.fee_type"
|
||||
:placeholder="TEXT.common.fields.type"
|
||||
clearable
|
||||
class="filter-select-comp"
|
||||
>
|
||||
<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" />
|
||||
<el-option :label="TEXT.enums.financeSpecialType.OTHER" value="OTHER" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-item-form">
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="goNew" class="header-action-btn">
|
||||
{{ TEXT.modules.financeSpecials.newTitle }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
@@ -109,8 +117,16 @@ const siteActiveMap = computed(() => {
|
||||
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 filteredItems = computed(() => {
|
||||
const keyword = filters.site_name.trim().toLowerCase();
|
||||
return items.value.filter((item) => {
|
||||
if (keyword && !String(item?.site_name || "").toLowerCase().includes(keyword)) return false;
|
||||
if (filters.fee_type && item?.fee_type !== filters.fee_type) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
const sortedItems = computed(() =>
|
||||
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
|
||||
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
|
||||
);
|
||||
|
||||
const loadSites = async () => {
|
||||
@@ -129,10 +145,7 @@ const load = async () => {
|
||||
if (!studyId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await listFinanceSpecials(studyId, {
|
||||
site_name: filters.site_name || undefined,
|
||||
fee_type: filters.fee_type || undefined,
|
||||
});
|
||||
const { data } = await listFinanceSpecials(studyId, {}) as any;
|
||||
items.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
@@ -144,7 +157,6 @@ const load = async () => {
|
||||
const resetFilters = () => {
|
||||
filters.site_name = "";
|
||||
filters.fee_type = "";
|
||||
load();
|
||||
};
|
||||
|
||||
const goNew = () => router.push("/finance/special/new");
|
||||
@@ -182,29 +194,59 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
.filter-card {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: white;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
.filter-item-form {
|
||||
margin-bottom: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
.filter-input-comp {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.filter-select-comp {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.knowledgeNotes.title }}</h1>
|
||||
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-input
|
||||
v-model="filters.site_name"
|
||||
:placeholder="TEXT.common.fields.site"
|
||||
clearable
|
||||
class="filter-input-comp"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
:placeholder="TEXT.common.fields.keyword"
|
||||
clearable
|
||||
class="filter-input-comp"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-item-form">
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="goNew" class="header-action-btn">
|
||||
{{ TEXT.modules.knowledgeNotes.newTitle }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-button type="primary" @click="goNew">{{ TEXT.modules.knowledgeNotes.newTitle }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card">
|
||||
<el-form :inline="true" :model="filters">
|
||||
<el-form-item :label="TEXT.common.fields.site">
|
||||
<el-input v-model="filters.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.keyword">
|
||||
<el-input v-model="filters.keyword" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.keyword" clearable />
|
||||
</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="sortedItems"
|
||||
v-loading="loading"
|
||||
@@ -87,8 +93,17 @@ const siteActiveMap = computed(() => {
|
||||
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 filteredItems = computed(() => {
|
||||
const siteKeyword = filters.site_name.trim().toLowerCase();
|
||||
const keyword = filters.keyword.trim().toLowerCase();
|
||||
return items.value.filter((item) => {
|
||||
if (siteKeyword && !String(item?.site_name || "").toLowerCase().includes(siteKeyword)) return false;
|
||||
if (keyword && !String(item?.title || "").toLowerCase().includes(keyword)) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
const sortedItems = computed(() =>
|
||||
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
|
||||
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_name)))
|
||||
);
|
||||
|
||||
const loadSites = async () => {
|
||||
@@ -107,10 +122,7 @@ const load = async () => {
|
||||
if (!studyId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await listKnowledgeNotes(studyId, {
|
||||
site_name: filters.site_name || undefined,
|
||||
keyword: filters.keyword || undefined,
|
||||
});
|
||||
const { data } = await listKnowledgeNotes(studyId, {}) as any;
|
||||
items.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
@@ -122,7 +134,6 @@ const load = async () => {
|
||||
const resetFilters = () => {
|
||||
filters.site_name = "";
|
||||
filters.keyword = "";
|
||||
load();
|
||||
};
|
||||
|
||||
const goNew = () => router.push("/knowledge/notes/new");
|
||||
@@ -160,29 +171,40 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
.filter-item-form {
|
||||
margin-bottom: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--ctms-text-secondary);
|
||||
.filter-input-comp {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.filter-card :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">项目概览</h1>
|
||||
<p class="study-meta">
|
||||
<span class="study-name">{{ study.currentStudy.name }}</span>
|
||||
<span class="study-divider">/</span>
|
||||
<span class="study-code">{{ study.currentStudy.code }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-meta">
|
||||
<el-tag v-if="usingDemo" effect="plain" type="info" class="demo-tag">示例数据</el-tag>
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<div class="ctms-page">
|
||||
<div class="ctms-page-header">
|
||||
<div>
|
||||
<h1 class="ctms-page-title">{{ TEXT.modules.startupFeasibilityEthics.title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="ctms-table-card">
|
||||
<el-tabs v-model="activeTab" class="ctms-tabs">
|
||||
@@ -240,7 +235,7 @@ const loadFeasibility = async () => {
|
||||
if (!studyId) return;
|
||||
loadingFeasibility.value = true;
|
||||
try {
|
||||
const { data } = await listFeasibility(studyId);
|
||||
const { data } = await listFeasibility(studyId) as any;
|
||||
feasibilityItems.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
@@ -254,7 +249,7 @@ const loadEthics = async () => {
|
||||
if (!studyId) return;
|
||||
loadingEthics.value = true;
|
||||
try {
|
||||
const { data } = await listEthics(studyId);
|
||||
const { data } = await listEthics(studyId) as any;
|
||||
ethicsItems.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
@@ -311,6 +306,12 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ctms-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ctms-tab-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.startupMeetingAuth.title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-table
|
||||
@@ -39,6 +34,7 @@
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { createKickoff, listKickoffs } from "../../api/startup";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
@@ -49,12 +45,14 @@ import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
const kickoffItems = ref<any[]>([]);
|
||||
const sites = ref<any[]>([]);
|
||||
const users = ref<any[]>([]);
|
||||
const members = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
|
||||
const memberNameMap = computed(() => {
|
||||
const map: Record<string, string> = {};
|
||||
@@ -62,7 +60,10 @@ const memberNameMap = computed(() => {
|
||||
if (u?.id) map[u.id] = u.full_name || u.username || u.id;
|
||||
});
|
||||
members.value.forEach((m: any) => {
|
||||
if (m?.user_id && !map[m.user_id]) map[m.user_id] = m.full_name || m.username || m.user_id;
|
||||
const user = m?.user;
|
||||
if (m?.user_id && !map[m.user_id]) {
|
||||
map[m.user_id] = user?.full_name || user?.username || user?.email || m.user_id;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
});
|
||||
@@ -111,16 +112,19 @@ const loadKickoffs = async () => {
|
||||
if (!studyId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const [sitesResp, kickoffResp, membersResp, usersResp] = await Promise.all([
|
||||
const requests = [
|
||||
fetchSites(studyId, { limit: 500 }),
|
||||
listKickoffs(studyId),
|
||||
listMembers(studyId, { limit: 500 }),
|
||||
fetchUsers({ limit: 500 }),
|
||||
]);
|
||||
];
|
||||
if (isAdmin.value) {
|
||||
requests.push(fetchUsers({ limit: 500 }));
|
||||
}
|
||||
const [sitesResp, kickoffResp, membersResp, usersResp] = await Promise.all(requests) as any[];
|
||||
sites.value = Array.isArray(sitesResp.data) ? sitesResp.data : sitesResp.data.items || [];
|
||||
kickoffItems.value = Array.isArray(kickoffResp.data) ? kickoffResp.data : kickoffResp.data.items || [];
|
||||
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
|
||||
users.value = usersResp.data?.items || [];
|
||||
users.value = usersResp?.data?.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
@@ -141,7 +145,7 @@ const onKickoffRowClick = async (row: any) => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await createKickoff(studyId, { site_id: row.site_id });
|
||||
const { data } = await createKickoff(studyId, { site_id: row.site_id }) as any;
|
||||
goKickoffDetail(data.id);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.createFailed);
|
||||
@@ -158,7 +162,7 @@ onMounted(() => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
|
||||
@@ -1,31 +1,91 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.subjectManagement.title }}</h1>
|
||||
</div>
|
||||
<el-button type="primary" @click="goNew">{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-card shadow="never" class="main-content-card">
|
||||
<div class="filter-container">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
:placeholder="TEXT.modules.subjectManagement.screeningNo"
|
||||
clearable
|
||||
class="filter-input-comp"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select
|
||||
v-model="filters.siteId"
|
||||
:placeholder="TEXT.common.fields.site"
|
||||
clearable
|
||||
filterable
|
||||
class="filter-select-comp"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Location /></el-icon>
|
||||
</template>
|
||||
<el-option :label="TEXT.common.labels.all" value="" />
|
||||
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="" class="filter-item-form">
|
||||
<el-select
|
||||
v-model="filters.status"
|
||||
:placeholder="TEXT.common.fields.status"
|
||||
clearable
|
||||
class="filter-select-comp"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
</template>
|
||||
<el-option :label="TEXT.common.labels.all" value="" />
|
||||
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="filter-actions-comp">
|
||||
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
|
||||
</el-form-item>
|
||||
<div class="filter-spacer"></div>
|
||||
<el-button type="primary" @click="goNew" class="header-action-btn">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
:data="pagedItems"
|
||||
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 prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" min-width="160">
|
||||
<template #default="scope">
|
||||
<div class="subject-info-cell">
|
||||
<span class="subject-no">{{ scope.row.subject_no || TEXT.common.fallback }}</span>
|
||||
<span v-if="scope.row.has_ae" class="ae-badge">AE</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.site" min-width="160">
|
||||
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="consent_date" :label="TEXT.common.fields.consentDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.consent_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
|
||||
<template #default="scope">{{ displayEnum(TEXT.enums.subjectStatus, scope.row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.enrollment_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.completion_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
@@ -40,15 +100,25 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.currentPage"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="filteredItems.length"
|
||||
layout="total, prev, pager, next, sizes"
|
||||
/>
|
||||
</div>
|
||||
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.subjectManagement.empty" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Search, Location, CircleCheck, Plus } from "@element-plus/icons-vue";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { deleteSubject, fetchSubjects } from "../../api/subjects";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
@@ -60,14 +130,31 @@ const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const items = ref<any[]>([]);
|
||||
const siteOptions = ref<Array<{ id: string; name: string }>>([]);
|
||||
const siteMap = ref<Record<string, string>>({});
|
||||
const siteActiveMap = ref<Record<string, boolean>>({});
|
||||
const filters = ref({
|
||||
keyword: "",
|
||||
siteId: study.currentSite?.id || "",
|
||||
status: "",
|
||||
});
|
||||
const pagination = ref({
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
const statusOptions = Object.entries(TEXT.enums.subjectStatus).map(([value, label]) => ({ value, label }));
|
||||
const resetFilters = () => {
|
||||
filters.value.keyword = "";
|
||||
filters.value.siteId = "";
|
||||
filters.value.status = "";
|
||||
};
|
||||
const loadSites = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
try {
|
||||
const { data } = await fetchSites(studyId, { limit: 500 });
|
||||
const list = Array.isArray(data) ? data : data.items || [];
|
||||
siteOptions.value = list.map((site: any) => ({ id: site.id, name: site.name }));
|
||||
siteMap.value = list.reduce((acc: Record<string, string>, site: any) => {
|
||||
acc[site.id] = site.name;
|
||||
return acc;
|
||||
@@ -121,12 +208,47 @@ const remove = async (row: any) => {
|
||||
};
|
||||
|
||||
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||
const filteredItems = computed(() => {
|
||||
const keyword = filters.value.keyword.trim().toLowerCase();
|
||||
return items.value.filter((item) => {
|
||||
if (keyword && !String(item?.subject_no || "").toLowerCase().includes(keyword)) return false;
|
||||
if (filters.value.siteId && item?.site_id !== filters.value.siteId) return false;
|
||||
if (filters.value.status && item?.status !== filters.value.status) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
const sortedItems = computed(() =>
|
||||
[...items.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
|
||||
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
|
||||
);
|
||||
const pagedItems = computed(() => {
|
||||
const start = (pagination.value.currentPage - 1) * pagination.value.pageSize;
|
||||
const end = start + pagination.value.pageSize;
|
||||
return sortedItems.value.slice(start, end);
|
||||
});
|
||||
const subjectRowClass = ({ row }: { row: any }) =>
|
||||
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
|
||||
|
||||
watch(
|
||||
() => [filters.value.keyword, filters.value.siteId, filters.value.status, pagination.value.pageSize],
|
||||
() => {
|
||||
pagination.value.currentPage = 1;
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => filteredItems.value.length,
|
||||
(total) => {
|
||||
const maxPage = Math.max(1, Math.ceil(total / pagination.value.pageSize));
|
||||
if (pagination.value.currentPage > maxPage) {
|
||||
pagination.value.currentPage = maxPage;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(() => study.currentSite, (newSite) => {
|
||||
filters.value.siteId = newSite?.id || "";
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
@@ -137,19 +259,92 @@ onMounted(async () => {
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
.main-content-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
.filter-item-form {
|
||||
margin-bottom: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.filter-select-comp,
|
||||
.filter-input-comp {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.subject-info-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.subject-no {
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-main);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.ae-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, #fff1f2 0%, #ffe4e6 100%);
|
||||
color: #be123c;
|
||||
border: 1px solid rgba(225, 29, 72, 0.2);
|
||||
padding: 2px 6px;
|
||||
height: 18px;
|
||||
line-height: normal;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
box-shadow: 0 1px 2px rgba(225, 29, 72, 0.05);
|
||||
}
|
||||
|
||||
.ae-badge::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background-color: #e11d48;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
:x2="axisRight"
|
||||
:y2="axisBottom"
|
||||
/>
|
||||
<g v-for="tick in yTicks" :key="tick.value" class="axis-tick">
|
||||
<g v-for="tick in yTicks" :key="`${tick.value}-${tick.minor ? 'm' : 'M'}`" class="axis-tick">
|
||||
<text :x="axisLeft - 12" :y="tickY(tick.value)" class="tick-label">
|
||||
{{ formatNumber(tick.value) }}
|
||||
</text>
|
||||
@@ -142,17 +142,13 @@ const maxValue = computed(() => {
|
||||
|
||||
const yTicks = computed(() => {
|
||||
const max = maxValue.value;
|
||||
const majorStep = Math.max(1, Math.ceil(max / 4));
|
||||
const top = majorStep * 4;
|
||||
const step = max <= 12 ? 1 : Math.max(1, Math.ceil(max / 6));
|
||||
const top = Math.max(1, Math.ceil(max / step) * step);
|
||||
const ticks: Array<{ value: number; minor: boolean }> = [];
|
||||
for (let i = 0; i <= 4; i += 1) {
|
||||
const value = top - i * majorStep;
|
||||
for (let value = top; value >= 0; value -= step) {
|
||||
ticks.push({ value, minor: false });
|
||||
if (i < 4) {
|
||||
ticks.push({ value: value - Math.round(majorStep / 2), minor: true });
|
||||
}
|
||||
}
|
||||
return ticks.filter((tick) => tick.value >= 0).sort((a, b) => b.value - a.value);
|
||||
return ticks;
|
||||
});
|
||||
|
||||
const axisMax = computed(() => Math.max(1, yTicks.value[0]?.value ?? 1));
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import {
|
||||
getKickoff,
|
||||
@@ -165,6 +166,7 @@ import { TEXT, requiredMessage } from "../../locales";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const loadingTraining = ref(false);
|
||||
@@ -175,6 +177,7 @@ const studyId = study.currentStudy?.id || "";
|
||||
const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: boolean }>({ name: "" });
|
||||
const users = ref<any[]>([]);
|
||||
const members = ref<any[]>([]);
|
||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
const kickoffAttachmentGroups = [
|
||||
{ entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
|
||||
{ entityType: "startup_kickoff_signin", title: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
|
||||
@@ -221,7 +224,10 @@ const memberNameMap = computed(() => {
|
||||
if (u?.id) map[u.id] = u.full_name || u.username || u.id;
|
||||
});
|
||||
members.value.forEach((m: any) => {
|
||||
if (m?.user_id && !map[m.user_id]) map[m.user_id] = m.full_name || m.username || m.user_id;
|
||||
const user = m?.user;
|
||||
if (m?.user_id && !map[m.user_id]) {
|
||||
map[m.user_id] = user?.full_name || user?.username || user?.email || m.user_id;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
});
|
||||
@@ -420,10 +426,11 @@ const goBack = () => router.push("/startup/meeting-auth");
|
||||
|
||||
onMounted(async () => {
|
||||
if (studyId) {
|
||||
const [membersResp, usersResp] = await Promise.all([
|
||||
listMembers(studyId, { limit: 500 }),
|
||||
fetchUsers({ limit: 500 }),
|
||||
]).catch(() => [null, null]);
|
||||
const requests = [listMembers(studyId, { limit: 500 })];
|
||||
if (isAdmin.value) {
|
||||
requests.push(fetchUsers({ limit: 500 }));
|
||||
}
|
||||
const [membersResp, usersResp] = await Promise.all(requests).catch(() => [null, null]);
|
||||
if (membersResp?.data) {
|
||||
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { createKickoff, getKickoff, updateKickoff } from "../../api/startup";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
@@ -67,6 +68,7 @@ import { TEXT } from "../../locales";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
const saving = ref(false);
|
||||
|
||||
@@ -77,6 +79,7 @@ const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: b
|
||||
const siteId = ref("");
|
||||
const users = ref<any[]>([]);
|
||||
const members = ref<any[]>([]);
|
||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||
|
||||
const memberNameMap = computed(() => {
|
||||
const map: Record<string, string> = {};
|
||||
@@ -84,7 +87,10 @@ const memberNameMap = computed(() => {
|
||||
if (u?.id) map[u.id] = u.full_name || u.username || u.id;
|
||||
});
|
||||
members.value.forEach((m: any) => {
|
||||
if (m?.user_id && !map[m.user_id]) map[m.user_id] = m.full_name || m.username || m.user_id;
|
||||
const user = m?.user;
|
||||
if (m?.user_id && !map[m.user_id]) {
|
||||
map[m.user_id] = user?.full_name || user?.username || user?.email || m.user_id;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
});
|
||||
@@ -174,10 +180,11 @@ const goBack = () => router.push("/startup/meeting-auth");
|
||||
|
||||
onMounted(async () => {
|
||||
if (studyId.value) {
|
||||
const [membersResp, usersResp] = await Promise.all([
|
||||
listMembers(studyId.value, { limit: 500 }),
|
||||
fetchUsers({ limit: 500 }),
|
||||
]).catch(() => [null, null]);
|
||||
const requests = [listMembers(studyId.value, { limit: 500 })];
|
||||
if (isAdmin.value) {
|
||||
requests.push(fetchUsers({ limit: 500 }));
|
||||
}
|
||||
const [membersResp, usersResp] = await Promise.all(requests).catch(() => [null, null]);
|
||||
if (membersResp?.data) {
|
||||
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
|
||||
}
|
||||
|
||||
@@ -1,27 +1,9 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ TEXT.modules.subjectDetail.title }}</h1>
|
||||
<p class="page-subtitle">{{ TEXT.modules.subjectDetail.subtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<template v-if="subjectEditing">
|
||||
<el-button type="primary" :loading="subjectSaving" @click="saveSubjectEdit">{{ TEXT.common.actions.save }}</el-button>
|
||||
<el-button @click="cancelSubjectEdit">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.subjectNo">{{ detail.subject_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.subjectManagement.screeningNo">{{ detail.subject_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ siteMap[detail.site_id] || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.status">
|
||||
<el-select v-if="subjectEditing" v-model="subjectForm.status" size="small">
|
||||
@@ -202,10 +184,23 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="term" :label="TEXT.common.fields.title" min-width="140" />
|
||||
<el-table-column prop="seriousness" :label="TEXT.common.fields.seriousness" width="120">
|
||||
<template #default="scope">{{ displayEnum(TEXT.enums.aeSeriousness, scope.row.seriousness) }}</template>
|
||||
<template #default="scope">
|
||||
<el-tag
|
||||
:type="getAeSeriousnessType(normalizeSeriousness(scope.row.seriousness))"
|
||||
size="small"
|
||||
effect="light"
|
||||
class="seriousness-tag"
|
||||
>
|
||||
{{ displayEnum(TEXT.enums.aeSeriousness, normalizeSeriousness(scope.row.seriousness)) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="causality" :label="TEXT.common.fields.causality" width="120">
|
||||
<template #default="scope">{{ displayText(scope.row.causality, TEXT.enums.aeCausality) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="outcome" :label="TEXT.common.fields.outcome" width="120">
|
||||
<template #default="scope">{{ displayText(scope.row.outcome, TEXT.enums.aeOutcome) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="causality" :label="TEXT.common.fields.causality" width="120" />
|
||||
<el-table-column prop="outcome" :label="TEXT.common.fields.outcome" width="120" />
|
||||
<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">
|
||||
@@ -254,9 +249,9 @@
|
||||
<el-form :model="visitForm" label-width="100px">
|
||||
<template v-if="!visitEditingId">
|
||||
<el-form-item :label="TEXT.common.fields.visitCode" required>
|
||||
<el-input v-model="visitForm.visit_code" />
|
||||
<el-input v-model="visitForm.visit_code" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.plannedDate">
|
||||
<el-form-item :label="TEXT.common.fields.plannedDate" :required="isFirstVisit">
|
||||
<el-date-picker v-model="visitForm.planned_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.windowStart">
|
||||
@@ -291,22 +286,18 @@
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.seriousness" required>
|
||||
<el-select v-model="aeForm.seriousness">
|
||||
<el-option :label="TEXT.enums.aeSeriousness.SERIOUS" value="SERIOUS" />
|
||||
<el-option :label="TEXT.enums.aeSeriousness.NON_SERIOUS" value="NON_SERIOUS" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.severity" required>
|
||||
<el-select v-model="aeForm.severity">
|
||||
<el-option :label="TEXT.enums.aeSeverity.MILD" value="MILD" />
|
||||
<el-option :label="TEXT.enums.aeSeverity.MODERATE" value="MODERATE" />
|
||||
<el-option :label="TEXT.enums.aeSeverity.SEVERE" value="SEVERE" />
|
||||
<el-option v-for="option in aeSeriousnessOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.causality">
|
||||
<el-input v-model="aeForm.causality" />
|
||||
<el-select v-model="aeForm.causality" clearable>
|
||||
<el-option v-for="option in aeCausalityOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.outcome">
|
||||
<el-input v-model="aeForm.outcome" />
|
||||
<el-select v-model="aeForm.outcome" clearable>
|
||||
<el-option v-for="option in aeOutcomeOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.remark">
|
||||
<el-input v-model="aeForm.description" type="textarea" :rows="3" />
|
||||
@@ -330,7 +321,7 @@ import { fetchSites } from "../../api/sites";
|
||||
import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories";
|
||||
import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/visits";
|
||||
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
|
||||
import { displayDate, displayEnum } from "../../utils/display";
|
||||
import { displayDate, displayEnum, displayText } from "../../utils/display";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
@@ -393,20 +384,62 @@ const visitForm = reactive<any>({
|
||||
actual_date: "",
|
||||
notes: "",
|
||||
});
|
||||
const isFirstVisit = computed(() => visitForm.visit_code === "V1");
|
||||
|
||||
const aeItems = ref<any[]>([]);
|
||||
const loadingAes = ref(false);
|
||||
const aeDialogVisible = ref(false);
|
||||
const aeEditingId = ref<string | null>(null);
|
||||
const aeCausalityOptions = [
|
||||
{ value: "肯定有关", label: TEXT.enums.aeCausality["肯定有关"] },
|
||||
{ value: "很可能有关", label: TEXT.enums.aeCausality["很可能有关"] },
|
||||
{ value: "可能有关", label: TEXT.enums.aeCausality["可能有关"] },
|
||||
{ value: "可能无关", label: TEXT.enums.aeCausality["可能无关"] },
|
||||
{ value: "无关", label: TEXT.enums.aeCausality["无关"] },
|
||||
{ value: "待评估", label: TEXT.enums.aeCausality["待评估"] },
|
||||
];
|
||||
const aeOutcomeOptions = [
|
||||
{ value: "痊愈", label: TEXT.enums.aeOutcome["痊愈"] },
|
||||
{ value: "好转", label: TEXT.enums.aeOutcome["好转"] },
|
||||
{ value: "持续", label: TEXT.enums.aeOutcome["持续"] },
|
||||
{ value: "恶化", label: TEXT.enums.aeOutcome["恶化"] },
|
||||
{ value: "死亡", label: TEXT.enums.aeOutcome["死亡"] },
|
||||
{ value: "失访", label: TEXT.enums.aeOutcome["失访"] },
|
||||
];
|
||||
const aeForm = reactive({
|
||||
term: "",
|
||||
onset_date: "",
|
||||
seriousness: "NON_SERIOUS",
|
||||
severity: "MILD",
|
||||
seriousness: "I",
|
||||
causality: "",
|
||||
outcome: "",
|
||||
description: "",
|
||||
});
|
||||
const aeSeriousnessOptions = [
|
||||
{ value: "I", label: TEXT.enums.aeSeriousness.I },
|
||||
{ value: "II", label: TEXT.enums.aeSeriousness.II },
|
||||
{ value: "III", label: TEXT.enums.aeSeriousness.III },
|
||||
{ value: "IV", label: TEXT.enums.aeSeriousness.IV },
|
||||
{ value: "V", label: TEXT.enums.aeSeriousness.V },
|
||||
];
|
||||
const normalizeSeriousness = (value?: string | null) => {
|
||||
if (!value) return "I";
|
||||
const normalized = String(value).trim().toUpperCase();
|
||||
const digitMap: Record<string, string> = {
|
||||
"1": "I",
|
||||
"2": "II",
|
||||
"3": "III",
|
||||
"4": "IV",
|
||||
"5": "V",
|
||||
};
|
||||
const legacyMap: Record<string, string> = {
|
||||
SERIOUS: "IV",
|
||||
NON_SERIOUS: "II",
|
||||
};
|
||||
if (digitMap[normalized]) return digitMap[normalized];
|
||||
if (legacyMap[normalized]) return legacyMap[normalized];
|
||||
if (["I", "II", "III", "IV", "V"].includes(normalized)) return normalized;
|
||||
return "I";
|
||||
};
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!studyId) return;
|
||||
@@ -487,7 +520,7 @@ const loadHistories = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
loadingHistory.value = true;
|
||||
try {
|
||||
const { data } = await listSubjectHistories(studyId, subjectId);
|
||||
const { data } = (await listSubjectHistories(studyId, subjectId)) as any;
|
||||
historyItems.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
@@ -551,8 +584,17 @@ const loadVisits = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
loadingVisits.value = true;
|
||||
try {
|
||||
const { data } = await fetchVisits(studyId, subjectId);
|
||||
visitItems.value = Array.isArray(data) ? data : data.items || [];
|
||||
const { data } = (await fetchVisits(studyId, subjectId)) as any;
|
||||
const items = Array.isArray(data) ? data : data.items || [];
|
||||
visitItems.value = items.sort((a: any, b: any) => {
|
||||
const getNum = (code: any) => {
|
||||
const text = String(code || "");
|
||||
if (!text.startsWith("V")) return Number.POSITIVE_INFINITY;
|
||||
const num = Number(text.slice(1));
|
||||
return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY;
|
||||
};
|
||||
return getNum(a?.visit_code) - getNum(b?.visit_code);
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
@@ -560,6 +602,21 @@ const loadVisits = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getNextVisitCode = () => {
|
||||
const used = new Set<number>();
|
||||
visitItems.value.forEach((item) => {
|
||||
const code = String(item?.visit_code || "");
|
||||
if (!code.startsWith("V")) return;
|
||||
const num = Number(code.slice(1));
|
||||
if (Number.isFinite(num) && num > 0) {
|
||||
used.add(num);
|
||||
}
|
||||
});
|
||||
let next = 1;
|
||||
while (used.has(next)) next += 1;
|
||||
return `V${next}`;
|
||||
};
|
||||
|
||||
const openVisitDialog = (row?: any) => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
@@ -570,7 +627,7 @@ const openVisitDialog = (row?: any) => {
|
||||
visitForm.actual_date = row.actual_date || "";
|
||||
visitForm.notes = row.notes || "";
|
||||
} else {
|
||||
visitForm.visit_code = "";
|
||||
visitForm.visit_code = getNextVisitCode();
|
||||
visitForm.planned_date = "";
|
||||
visitForm.window_start = "";
|
||||
visitForm.window_end = "";
|
||||
@@ -657,6 +714,22 @@ const getVisitStatusTagType = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getAeSeriousnessType = (seriousness: string) => {
|
||||
switch (seriousness) {
|
||||
case "I":
|
||||
return "info";
|
||||
case "II":
|
||||
return "success";
|
||||
case "III":
|
||||
return "warning";
|
||||
case "IV":
|
||||
case "V":
|
||||
return "danger";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
};
|
||||
|
||||
const saveVisit = async () => {
|
||||
if (isReadOnly.value) {
|
||||
ElMessage.warning("中心已停用");
|
||||
@@ -671,6 +744,10 @@ const saveVisit = async () => {
|
||||
};
|
||||
await updateVisit(studyId, subjectId, visitEditingId.value, payload);
|
||||
} else {
|
||||
if (visitForm.visit_code === "V1" && !visitForm.planned_date) {
|
||||
ElMessage.error("首次新增访视必须填写计划访视日期");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
subject_id: subjectId,
|
||||
visit_code: visitForm.visit_code,
|
||||
@@ -707,7 +784,7 @@ const loadAes = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
loadingAes.value = true;
|
||||
try {
|
||||
const { data } = await fetchAes(studyId, { subject_id: subjectId });
|
||||
const { data } = (await fetchAes(studyId, { subject_id: subjectId })) as any;
|
||||
aeItems.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
|
||||
@@ -725,8 +802,7 @@ const openAeDialog = (row?: any) => {
|
||||
Object.assign(aeForm, {
|
||||
term: row?.term || "",
|
||||
onset_date: row?.onset_date || "",
|
||||
seriousness: row?.seriousness || "NON_SERIOUS",
|
||||
severity: row?.severity || "MILD",
|
||||
seriousness: normalizeSeriousness(row?.seriousness),
|
||||
causality: row?.causality || "",
|
||||
outcome: row?.outcome || "",
|
||||
description: row?.description || "",
|
||||
@@ -747,7 +823,6 @@ const saveAe = async () => {
|
||||
term: aeForm.term,
|
||||
onset_date: aeForm.onset_date || null,
|
||||
seriousness: aeForm.seriousness,
|
||||
severity: aeForm.severity,
|
||||
causality: aeForm.causality || null,
|
||||
outcome: aeForm.outcome || null,
|
||||
description: aeForm.description || null,
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
|
||||
<el-card>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item :label="TEXT.common.fields.subjectNo" required>
|
||||
<el-form-item :label="TEXT.modules.subjectManagement.screeningNo" required>
|
||||
<el-input
|
||||
v-model="form.subject_no"
|
||||
:disabled="isEdit || isReadOnly"
|
||||
:placeholder="TEXT.common.placeholders.input + TEXT.common.fields.subjectNo"
|
||||
:placeholder="TEXT.common.placeholders.input + TEXT.modules.subjectManagement.screeningNo"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.site" required>
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
<div class="workbench-header">
|
||||
<div class="header-content">
|
||||
<h1 class="welcome-title">{{ TEXT.modules.workbench.title }}</h1>
|
||||
<p class="welcome-subtitle">
|
||||
{{ TEXT.modules.workbench.subtitle.replace("{role}", roleLabel) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-tag effect="dark" class="role-badge">{{ roleLabel }}</el-tag>
|
||||
@@ -24,6 +21,9 @@
|
||||
/>
|
||||
|
||||
<template v-if="currentStudyId">
|
||||
<!-- 汇总框 -->
|
||||
<CenterSummary :data="centerSummaryData" :loading="loading" />
|
||||
|
||||
<!-- 核心看板层 -->
|
||||
<div class="stats-row">
|
||||
<div class="stats-col">
|
||||
@@ -36,13 +36,41 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="stats-col">
|
||||
<SectionCard
|
||||
:title="TEXT.modules.workbench.overdueTitle"
|
||||
:items="overdueList"
|
||||
:loading="loading"
|
||||
:empty-text="TEXT.modules.workbench.overdueEmpty"
|
||||
@more="goMore(overdueMorePath)"
|
||||
/>
|
||||
<div class="notifications-card">
|
||||
<div class="notifications-header">
|
||||
<div class="notifications-title-group">
|
||||
<span class="notifications-title">{{ TEXT.modules.projectOverview.notificationsTitle }}</span>
|
||||
<span class="notifications-subtitle">{{ TEXT.modules.projectOverview.notificationsSubtitle }}</span>
|
||||
</div>
|
||||
<el-button v-if="notifications.length" type="primary" link @click="goMore('/documents')" class="notifications-more">
|
||||
{{ TEXT.common.actions.more }} <el-icon class="el-icon--right"><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<el-skeleton v-if="loading" :rows="3" animated />
|
||||
<StateEmpty v-else-if="!notifications.length" :description="TEXT.modules.projectOverview.notificationsEmpty" />
|
||||
<div v-else class="notification-list">
|
||||
<div
|
||||
class="notification-item"
|
||||
v-for="item in notifications.slice(0, 5)"
|
||||
:key="item.id"
|
||||
@click="openDocument(item.document_id)"
|
||||
>
|
||||
<div class="notification-main">
|
||||
<div class="notification-title">
|
||||
<span class="notification-doc">{{ item.document_title }}</span>
|
||||
<span class="notification-version">V{{ item.version_no }}</span>
|
||||
</div>
|
||||
<div class="notification-meta">
|
||||
<span class="notification-time">{{ formatDate(item.effective_at || item.created_at) }}</span>
|
||||
<span v-if="item.change_summary" class="notification-summary">{{ item.change_summary }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notification-update-badge">
|
||||
<span class="update-text">更新</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,11 +78,11 @@
|
||||
<div class="secondary-row">
|
||||
<div class="action-col">
|
||||
<SectionCard
|
||||
:title="TEXT.modules.workbench.actionTitle"
|
||||
:items="actionList"
|
||||
:title="TEXT.modules.workbench.overdueTitle"
|
||||
:items="overdueList"
|
||||
:loading="loading"
|
||||
:empty-text="TEXT.modules.workbench.actionEmpty"
|
||||
@more="goMore(actionMorePath)"
|
||||
:empty-text="TEXT.modules.workbench.overdueEmpty"
|
||||
@more="goMore(overdueMorePath)"
|
||||
/>
|
||||
</div>
|
||||
<div class="quick-col">
|
||||
@@ -83,10 +111,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 个人备忘层 -->
|
||||
<div v-if="showPersonalTodo" class="todo-row">
|
||||
<PersonalTodo :storage-key="todoStorageKey" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -98,10 +122,14 @@ import { ElMessage } from "element-plus";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { fetchAes } from "../../api/aes";
|
||||
import { listFinanceContracts } from "../../api/financeContracts";
|
||||
import { Right, Timer, UserFilled, Warning, Money, Collection } from "@element-plus/icons-vue";
|
||||
import { fetchLostVisits, fetchCenterSummary } from "../../api/dashboard";
|
||||
import { listNotifications } from "../../api/notifications";
|
||||
import { Right, Timer, Warning, Money, Collection, RefreshRight, ArrowRight } from "@element-plus/icons-vue";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { displayEnum } from "../../utils/display";
|
||||
import { TEXT } from "../../locales";
|
||||
import type { NotificationItem } from "../../types/notifications";
|
||||
import type { VisitLostItem } from "../../types/visits";
|
||||
|
||||
interface WorkItem {
|
||||
title: string;
|
||||
@@ -112,8 +140,7 @@ interface WorkItem {
|
||||
}
|
||||
|
||||
const SectionCard = defineAsyncComponent(() => import("./components/SectionCard.vue"));
|
||||
const PersonalTodo = defineAsyncComponent(() => import("./components/PersonalTodo.vue"));
|
||||
|
||||
const CenterSummary = defineAsyncComponent(() => import("./components/CenterSummary.vue"));
|
||||
const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
const router = useRouter();
|
||||
@@ -121,15 +148,14 @@ const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const todayList = ref<WorkItem[]>([]);
|
||||
const overdueList = ref<WorkItem[]>([]);
|
||||
const actionList = ref<WorkItem[]>([]);
|
||||
const notifications = ref<NotificationItem[]>([]);
|
||||
const lostVisits = ref<VisitLostItem[]>([]);
|
||||
const centerSummaryData = ref<any[]>([]);
|
||||
|
||||
const currentStudyId = computed(() => study.currentStudy?.id || "");
|
||||
const todayStr = computed(() => new Date().toISOString().slice(0, 10));
|
||||
const roleLabel = computed(() => displayEnum(TEXT.enums.userRole, auth.user?.role));
|
||||
const emptyText = TEXT.modules.workbench.quickEmpty;
|
||||
const showPersonalTodo = computed(() => role.value !== "ADMIN");
|
||||
const todoStorageKey = computed(() => `workbench_todos_${auth.user?.id || "guest"}`);
|
||||
|
||||
const role = computed(() => auth.user?.role || "");
|
||||
const quickActions = computed(() => {
|
||||
if (!currentStudyId.value) return [];
|
||||
@@ -150,10 +176,6 @@ const quickActions = computed(() => {
|
||||
|
||||
const todayMorePath = computed(() => "/subjects");
|
||||
const overdueMorePath = computed(() => "/subjects");
|
||||
const actionMorePath = computed(() => {
|
||||
if (role.value === "PM" || role.value === "ADMIN") return "/finance/contracts";
|
||||
return "/subjects";
|
||||
});
|
||||
|
||||
const isOverdue = (d?: string | null) => {
|
||||
if (!d) return false;
|
||||
@@ -162,18 +184,18 @@ const isOverdue = (d?: string | null) => {
|
||||
|
||||
const toAeItem = (ae: any): WorkItem => ({
|
||||
title: ae.term || TEXT.modules.workbench.aeTitleFallback,
|
||||
subtitle: study.currentStudy?.name || "",
|
||||
subtitle: "",
|
||||
status: ae.status,
|
||||
overdue: isOverdue(ae.expected_resolution_date || ae.updated_at),
|
||||
path: ae.subject_id ? `/subjects/${ae.subject_id}` : "/subjects",
|
||||
});
|
||||
|
||||
const toContractItem = (fi: any): WorkItem => ({
|
||||
title: fi.contract_no || TEXT.modules.workbench.contractTitleFallback,
|
||||
subtitle: study.currentStudy?.name || "",
|
||||
status: fi.site_name,
|
||||
overdue: false,
|
||||
path: `/finance/contracts/${fi.id}`,
|
||||
const toLostVisitItem = (visit: VisitLostItem): WorkItem => ({
|
||||
title: `${visit.subject_no} ${visit.visit_code}`,
|
||||
subtitle: "",
|
||||
status: isNewLostVisit(visit.updated_at) ? TEXT.modules.workbench.newLostVisit : TEXT.enums.visitStatus.LOST,
|
||||
overdue: true,
|
||||
path: `/subjects/${visit.subject_id}`,
|
||||
});
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -182,13 +204,21 @@ const loadData = async () => {
|
||||
try {
|
||||
const studyId = currentStudyId.value;
|
||||
const aesReq = fetchAes(studyId, { status: "NEW", limit: 50 });
|
||||
|
||||
const [aesRes, financeRes] = await Promise.allSettled([aesReq, listFinanceContracts(studyId, { limit: 50 })]);
|
||||
const overdueReq = fetchAes(studyId, { overdue: true, limit: 50 });
|
||||
const [aesRes, overdueRes, noticeRes, lostRes, centerRes] = await Promise.allSettled([
|
||||
aesReq,
|
||||
overdueReq,
|
||||
listNotifications(studyId, { limit: 5 }),
|
||||
fetchLostVisits(studyId, { limit: 20 }),
|
||||
fetchCenterSummary(studyId),
|
||||
]);
|
||||
const aes = aesRes.status === "fulfilled" ? (aesRes.value.data.items || aesRes.value.data || []) : [];
|
||||
const finances =
|
||||
financeRes.status === "fulfilled" ? (financeRes.value.data.items || financeRes.value.data || []) : [];
|
||||
const overdueAes = overdueRes.status === "fulfilled" ? (overdueRes.value.data.items || overdueRes.value.data || []) : [];
|
||||
notifications.value = noticeRes.status === "fulfilled" ? noticeRes.value.data || [] : [];
|
||||
lostVisits.value = lostRes.status === "fulfilled" ? lostRes.value.data || [] : [];
|
||||
centerSummaryData.value = centerRes.status === "fulfilled" ? centerRes.value.data || [] : [];
|
||||
|
||||
buildSections(aes, finances);
|
||||
buildSections(aes, overdueAes, lostVisits.value);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.workbench.loadFailed);
|
||||
} finally {
|
||||
@@ -196,29 +226,23 @@ const loadData = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const buildSections = (aes: any[], finances: any[]) => {
|
||||
const buildSections = (aes: any[], overdueAes: any[], lost: VisitLostItem[]) => {
|
||||
todayList.value = [];
|
||||
overdueList.value = [];
|
||||
actionList.value = [];
|
||||
|
||||
const openAes = aes.filter((a) => a.status !== "CLOSED");
|
||||
const financePending = finances;
|
||||
const overdueAes = openAes.filter((a) => isOverdue(a.expected_resolution_date || a.updated_at));
|
||||
const lostVisitItems = lost.map(toLostVisitItem);
|
||||
|
||||
if (role.value === "CRA") {
|
||||
todayList.value = openAes.slice(0, 5).map(toAeItem);
|
||||
overdueList.value = overdueAes.slice(0, 5).map(toAeItem);
|
||||
actionList.value = openAes.slice(0, 5).map(toAeItem);
|
||||
overdueList.value = [...lostVisitItems, ...overdueAes.map(toAeItem)].slice(0, 5);
|
||||
} else if (role.value === "PM" || role.value === "ADMIN") {
|
||||
todayList.value = openAes.slice(0, 5).map(toAeItem);
|
||||
overdueList.value = overdueAes.slice(0, 5).map(toAeItem);
|
||||
const financeTodo = financePending.slice(0, 3).map(toContractItem);
|
||||
actionList.value = [...financeTodo, ...openAes.slice(0, 2).map(toAeItem)].slice(0, 5);
|
||||
overdueList.value = [...lostVisitItems, ...overdueAes.map(toAeItem)].slice(0, 5);
|
||||
} else if (role.value === "PV") {
|
||||
const aeOpen = aes.filter((a) => a.status !== "CLOSED");
|
||||
todayList.value = aeOpen.slice(0, 5).map(toAeItem);
|
||||
overdueList.value = aeOpen.filter((a) => isOverdue(a.updated_at)).slice(0, 5).map(toAeItem);
|
||||
actionList.value = aeOpen.slice(0, 5).map(toAeItem);
|
||||
overdueList.value = [...lostVisitItems, ...overdueAes.map(toAeItem)].slice(0, 5);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -227,6 +251,23 @@ const goMore = (path: string) => {
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return TEXT.common.fallback;
|
||||
return value.replace("T", " ").replace("Z", "").split(".")[0];
|
||||
};
|
||||
|
||||
const isNewLostVisit = (value?: string | null) => {
|
||||
if (!value) return false;
|
||||
const updatedAt = new Date(value);
|
||||
if (Number.isNaN(updatedAt.getTime())) return false;
|
||||
const now = new Date();
|
||||
return now.getTime() - updatedAt.getTime() <= 24 * 60 * 60 * 1000;
|
||||
};
|
||||
|
||||
const openDocument = (documentId: string) => {
|
||||
router.push(`/documents/${documentId}`);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.user && auth.token) {
|
||||
await auth.fetchMe().catch(() => {});
|
||||
@@ -239,141 +280,458 @@ onMounted(async () => {
|
||||
.workbench-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
gap: 28px;
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 头部区域 - 添加渐变背景 */
|
||||
.workbench-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
padding: 32px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 16px;
|
||||
margin: -8px -8px 0 -8px;
|
||||
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.2);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workbench-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.welcome-subtitle {
|
||||
margin: 8px 0 0;
|
||||
font-size: 15px;
|
||||
color: var(--ctms-text-secondary);
|
||||
margin: 10px 0 0;
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
background-color: var(--ctms-primary);
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
padding: 0 12px;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
padding: 0 16px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stats-row, .secondary-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.todo-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.study-alert {
|
||||
border: none;
|
||||
border-left: 4px solid var(--ctms-warning);
|
||||
background-color: #fffbe6;
|
||||
background: linear-gradient(90deg, #fffbe6 0%, #ffffff 100%);
|
||||
box-shadow: var(--ctms-shadow-sm);
|
||||
}
|
||||
|
||||
/* 快捷入口卡片样式 */
|
||||
/* 快捷入口卡片样式 - 大幅优化 */
|
||||
.quick-entry-card {
|
||||
background-color: #fff;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8f9ff 100%);
|
||||
border: 1px solid rgba(102, 126, 234, 0.1);
|
||||
border-radius: 16px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.quick-entry-card:hover {
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.entry-header {
|
||||
padding: 16px 20px;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--ctms-border-color);
|
||||
background-color: #fafafa;
|
||||
border-bottom: 1px solid rgba(102, 126, 234, 0.08);
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.03) 0%, rgba(118, 75, 162, 0.02) 100%);
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-main);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.entry-more {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quick-grid {
|
||||
padding: 20px;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 20px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.quick-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
padding: 16px 12px;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.quick-item:hover {
|
||||
background-color: var(--ctms-primary-light);
|
||||
transform: translateY(-2px);
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
box-shadow: 0 12px 28px rgba(102, 126, 234, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.quick-icon-box {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: #fff;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
border-radius: 10px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: var(--ctms-primary);
|
||||
box-shadow: var(--ctms-shadow-sm);
|
||||
transition: all 0.3s ease;
|
||||
font-size: 26px;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
|
||||
transition: all 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.quick-icon-box::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 16px;
|
||||
padding: 2px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.1));
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
}
|
||||
|
||||
.quick-item:hover .quick-icon-box {
|
||||
border-color: var(--ctms-primary);
|
||||
background-color: var(--ctms-primary);
|
||||
color: #fff;
|
||||
background: #ffffff;
|
||||
color: #667eea;
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
box-shadow: 0 12px 32px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.quick-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-regular);
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quick-item:hover .quick-label {
|
||||
color: #ffffff;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 通知卡片 - 增强样式 */
|
||||
.notifications-card {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fef7ff 100%);
|
||||
border: 1px solid rgba(118, 75, 162, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.notifications-card:hover {
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.notifications-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid rgba(118, 75, 162, 0.08);
|
||||
}
|
||||
|
||||
.notifications-title-group {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notifications-more {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.notifications-more:hover {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.notifications-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.notifications-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(102, 126, 234, 0.08);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%);
|
||||
border-color: rgba(102, 126, 234, 0.2);
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.notification-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-main);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.notification-version {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.notification-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.notification-summary {
|
||||
max-width: 360px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 锯齿状更新徽章 - 扁平版 */
|
||||
.notification-update-badge {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.notification-update-badge::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #ff4757 0%, #ff6b81 100%);
|
||||
clip-path: polygon(
|
||||
10% 0%,
|
||||
20% 8%,
|
||||
30% 0%,
|
||||
40% 8%,
|
||||
50% 0%,
|
||||
60% 8%,
|
||||
70% 0%,
|
||||
80% 8%,
|
||||
90% 0%,
|
||||
100% 15%,
|
||||
95% 50%,
|
||||
100% 85%,
|
||||
90% 100%,
|
||||
80% 92%,
|
||||
70% 100%,
|
||||
60% 92%,
|
||||
50% 100%,
|
||||
40% 92%,
|
||||
30% 100%,
|
||||
20% 92%,
|
||||
10% 100%,
|
||||
0% 85%,
|
||||
5% 50%,
|
||||
0% 15%
|
||||
);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 透明内层 - 镂空效果 */
|
||||
.notification-update-badge::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: calc(100% - 4px);
|
||||
height: calc(100% - 4px);
|
||||
background: #ffffff;
|
||||
clip-path: polygon(
|
||||
10% 0%,
|
||||
20% 10%,
|
||||
30% 0%,
|
||||
40% 10%,
|
||||
50% 0%,
|
||||
60% 10%,
|
||||
70% 0%,
|
||||
80% 10%,
|
||||
90% 0%,
|
||||
100% 18%,
|
||||
94% 50%,
|
||||
100% 82%,
|
||||
90% 100%,
|
||||
80% 90%,
|
||||
70% 100%,
|
||||
60% 90%,
|
||||
50% 100%,
|
||||
40% 90%,
|
||||
30% 100%,
|
||||
20% 90%,
|
||||
10% 100%,
|
||||
0% 82%,
|
||||
6% 50%,
|
||||
0% 18%
|
||||
);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.update-text {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: #1a1a2e;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.notification-item:hover .notification-update-badge {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.notification-item:hover .notification-update-badge::before {
|
||||
box-shadow: 0 4px 16px rgba(255, 71, 87, 0.4);
|
||||
}
|
||||
|
||||
.empty-quick {
|
||||
padding: 40px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--ctms-text-disabled);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.stats-row, .secondary-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workbench-header {
|
||||
padding: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="center-summary-box">
|
||||
<div class="box-header">
|
||||
<span class="box-title">各中心进展汇总</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-table-container">
|
||||
<el-table
|
||||
:data="data"
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
empty-text="暂无数据"
|
||||
class="custom-table"
|
||||
>
|
||||
<el-table-column prop="name" label="管理中心" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="center-name">{{ row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="stage" label="进展阶段" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStageType(row.stage_status)" effect="light" size="small" round>
|
||||
{{ row.stage }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="实际入组例数/计划例数" width="200" align="right">
|
||||
<template #default="{ row }">
|
||||
<div class="enrollment-ratio">
|
||||
<span class="actual">{{ row.actual_enrolled }}</span>
|
||||
<span class="separator">/</span>
|
||||
<span class="planned">{{ row.planned_enrolled }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
|
||||
defineProps<{
|
||||
data: any[];
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const getStageType = (status?: string) => {
|
||||
if (status === "COMPLETED") return "success";
|
||||
if (status === "IN_PROGRESS") return "warning";
|
||||
if (status === "BLOCKED") return "danger";
|
||||
return "info";
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.center-summary-box {
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.box-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.box-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main, #1a1a2e);
|
||||
}
|
||||
|
||||
.center-name {
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-main, #1a1a2e);
|
||||
}
|
||||
|
||||
.enrollment-ratio {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actual {
|
||||
color: var(--ctms-primary, #667eea);
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 0 4px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.planned {
|
||||
color: var(--ctms-text-secondary, #808080);
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,12 @@
|
||||
<el-card shadow="never" class="section-card">
|
||||
<template #header>
|
||||
<div class="section-header">
|
||||
<span class="section-title">{{ title }}</span>
|
||||
<div class="section-title-wrapper">
|
||||
<div class="section-icon">
|
||||
<el-icon><component :is="getIcon()" /></el-icon>
|
||||
</div>
|
||||
<span class="section-title">{{ title }}</span>
|
||||
</div>
|
||||
<el-button v-if="!loading" type="primary" link @click="$emit('more')" class="more-btn">
|
||||
{{ TEXT.common.actions.more }} <el-icon class="el-icon--right"><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
@@ -43,12 +48,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
import { ArrowRight } from "@element-plus/icons-vue";
|
||||
import { ArrowRight, Calendar, Warning, Document } from "@element-plus/icons-vue";
|
||||
import StateEmpty from "../../../components/StateEmpty.vue";
|
||||
import StateLoading from "../../../components/StateLoading.vue";
|
||||
import { TEXT } from "../../../locales";
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
items: any[];
|
||||
loading?: boolean;
|
||||
@@ -62,32 +67,89 @@ const go = (path: string) => {
|
||||
if (!path) return;
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
// 根据标题返回不同的图标
|
||||
if (props.title.includes("今日") || props.title.includes("待办")) return Calendar;
|
||||
if (props.title.includes("逾期")) return Warning;
|
||||
return Document;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-card {
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(102, 126, 234, 0.12);
|
||||
border-radius: 16px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fafcff 100%);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.section-card:hover {
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
:deep(.el-card__header) {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--ctms-border-color);
|
||||
background-color: #fafafa;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid rgba(102, 126, 234, 0.08);
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.03) 0%, rgba(118, 75, 162, 0.02) 100%);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.25);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.section-card:hover .section-icon {
|
||||
transform: rotate(-5deg) scale(1.05);
|
||||
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.35);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-main);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
font-size: 13px;
|
||||
color: var(--ctms-primary);
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.more-btn:hover {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.section-body {
|
||||
@@ -101,13 +163,25 @@ const go = (path: string) => {
|
||||
}
|
||||
|
||||
.list-item {
|
||||
padding: 16px 20px;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-bottom: 1px solid var(--ctms-border-color);
|
||||
transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
border-bottom: 1px solid rgba(102, 126, 234, 0.06);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.list-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.list-item:last-child {
|
||||
@@ -115,23 +189,35 @@ const go = (path: string) => {
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background-color: var(--ctms-primary-light);
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.04) 0%, rgba(118, 75, 162, 0.04) 100%);
|
||||
transform: translateX(6px);
|
||||
}
|
||||
|
||||
.list-item:hover::before {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.item-main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
margin-right: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-main);
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.list-item:hover .item-title {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.item-subtitle {
|
||||
@@ -140,13 +226,41 @@ const go = (path: string) => {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
border-radius: 4px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.status-tag.el-tag--danger {
|
||||
background: linear-gradient(135deg, rgba(194, 75, 75, 0.12) 0%, rgba(194, 75, 75, 0.08) 100%);
|
||||
color: var(--ctms-danger);
|
||||
border-color: rgba(194, 75, 75, 0.2);
|
||||
}
|
||||
|
||||
.status-tag.el-tag--info {
|
||||
background: linear-gradient(135deg, rgba(100, 116, 139, 0.12) 0%, rgba(100, 116, 139, 0.08) 100%);
|
||||
color: var(--ctms-info);
|
||||
border-color: rgba(100, 116, 139, 0.2);
|
||||
}
|
||||
|
||||
.list-item:hover .status-tag {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 8px 0;
|
||||
padding: 16px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user