费用管理内容优化-初步

This commit is contained in:
Cheng Zhou
2026-01-13 16:49:55 +08:00
parent 0c7c03069a
commit 1db36f40b0
43 changed files with 5229 additions and 26 deletions
+70 -14
View File
@@ -1,8 +1,13 @@
<template>
<el-card class="kpi-card" shadow="never">
<el-card class="kpi-card" :class="[`type-${type}`]" shadow="hover">
<div class="kpi-header">
<span class="kpi-title">{{ title }}</span>
<el-icon v-if="icon" class="kpi-icon"><component :is="icon" /></el-icon>
<div class="kpi-title-wrapper">
<div class="kpi-icon-wrapper" v-if="icon">
<el-icon class="kpi-icon"><component :is="icon" /></el-icon>
</div>
<span class="kpi-title">{{ title }}</span>
</div>
<slot name="header-suffix"></slot>
</div>
<div class="kpi-content">
<div v-if="loading" class="kpi-loading">
@@ -22,6 +27,8 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
title: string;
value: string | number;
@@ -29,38 +36,85 @@ interface Props {
loading?: boolean;
icon?: any;
unit?: string;
type?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
}
defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
type: 'default',
});
</script>
<style scoped>
.kpi-card {
border: 1px solid var(--ctms-border-color);
transition: var(--ctms-transition);
transition: all 0.3s ease;
height: 100%;
border-radius: 12px;
overflow: hidden;
position: relative;
--el-card-padding: 12px 16px;
}
.kpi-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background-color: transparent;
transition: background-color 0.3s ease;
}
.kpi-card.type-primary::before { background-color: var(--el-color-primary); }
.kpi-card.type-success::before { background-color: var(--el-color-success); }
.kpi-card.type-warning::before { background-color: var(--el-color-warning); }
.kpi-card.type-danger::before { background-color: var(--el-color-danger); }
.kpi-card.type-info::before { background-color: var(--el-color-info); }
.kpi-card:hover {
border-color: var(--ctms-border-color-hover);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.kpi-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
margin-bottom: 8px;
}
.kpi-title-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.kpi-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background-color: var(--ctms-bg-color-page);
color: var(--ctms-text-secondary);
}
.type-primary .kpi-icon-wrapper { color: var(--el-color-primary); background-color: var(--el-color-primary-light-9); }
.type-success .kpi-icon-wrapper { color: var(--el-color-success); background-color: var(--el-color-success-light-9); }
.type-warning .kpi-icon-wrapper { color: var(--el-color-warning); background-color: var(--el-color-warning-light-9); }
.type-danger .kpi-icon-wrapper { color: var(--el-color-danger); background-color: var(--el-color-danger-light-9); }
.type-info .kpi-icon-wrapper { color: var(--el-color-info); background-color: var(--el-color-info-light-9); }
.kpi-title {
color: var(--ctms-text-secondary);
font-size: 13px;
font-weight: 600;
font-size: 14px;
font-weight: 500;
}
.kpi-icon {
font-size: 16px;
color: var(--ctms-text-secondary);
}
.kpi-content {
@@ -75,24 +129,26 @@ defineProps<Props>();
}
.kpi-value {
font-size: 28px;
font-weight: 600;
font-size: 32px;
font-weight: 700;
color: var(--ctms-text-main);
line-height: 1.2;
font-family: var(--el-font-family);
}
.kpi-unit {
font-size: 14px;
color: var(--ctms-text-secondary);
font-weight: 500;
margin-left: 2px;
}
.kpi-subtext {
color: var(--ctms-text-secondary);
font-size: 12px;
font-size: 13px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--ctms-border-color);
border-top: 1px solid var(--ctms-border-color-lighter);
}
.kpi-loading {
+7 -5
View File
@@ -39,13 +39,13 @@
<el-icon><House /></el-icon>
<span>{{ TEXT.menu.projectOverview }}</span>
</el-menu-item>
<el-sub-menu index="finance">
<el-sub-menu index="fees">
<template #title>
<el-icon><Coin /></el-icon>
<span>{{ TEXT.menu.finance }}</span>
</template>
<el-menu-item index="/finance/contracts">{{ TEXT.menu.financeContracts }}</el-menu-item>
<el-menu-item index="/finance/special">{{ TEXT.menu.financeSpecials }}</el-menu-item>
<el-menu-item index="/fees/contracts">{{ TEXT.menu.feeContracts }}</el-menu-item>
<el-menu-item index="/fees/special">{{ TEXT.menu.feeSpecials }}</el-menu-item>
</el-sub-menu>
<el-sub-menu index="drug">
<template #title>
@@ -170,8 +170,10 @@ const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
const activeMenu = computed(() => {
const path = route.path;
if (path.startsWith("/project/")) return "/project/overview";
if (path.startsWith("/finance/contracts")) return "/finance/contracts";
if (path.startsWith("/finance/special")) return "/finance/special";
if (path.startsWith("/fees/contracts")) return "/fees/contracts";
if (path.startsWith("/fees/special")) return "/fees/special";
if (path.startsWith("/finance/contracts")) return "/fees/contracts";
if (path.startsWith("/finance/special")) return "/fees/special";
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
if (path.startsWith("/file-versions")) return "/file-versions";
if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics";
+1 -1
View File
@@ -36,7 +36,7 @@ const actions = [
{ label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth", icon: Timer },
{ label: TEXT.menu.subjects, path: "/subjects", icon: UserFilled },
{ label: TEXT.menu.drugShipments, path: "/drug/shipments", icon: Management },
{ label: TEXT.menu.finance, path: "/finance/contracts", icon: Money },
{ label: TEXT.menu.finance, path: "/fees/contracts", icon: Money },
{ label: TEXT.menu.knowledge, path: "/knowledge/medical-consult", icon: Collection },
];
+3 -2
View File
@@ -1,6 +1,6 @@
<template>
<div class="state state-error">
<el-icon class="state-icon"><CircleCloseFilled /></el-icon>
<el-icon class="state-icon"><CircleClose /></el-icon>
<div class="state-title">{{ title }}</div>
<div v-if="description" class="state-desc">{{ description }}</div>
<div v-if="$slots.action" class="state-action">
@@ -10,7 +10,7 @@
</template>
<script setup lang="ts">
import { CircleCloseFilled } from "@element-plus/icons-vue";
import { CircleClose } from "@element-plus/icons-vue";
import { TEXT } from "../locales";
withDefaults(
@@ -32,6 +32,7 @@ withDefaults(
align-items: center;
justify-content: center;
padding: 40px 12px;
color: var(--ctms-text-secondary);
text-align: center;
}
@@ -24,8 +24,11 @@
{{ displayDateTime(scope.row.uploaded_at) }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="180">
<el-table-column :label="TEXT.common.labels.actions" width="220">
<template #default="scope">
<el-button link type="primary" size="small" @click="preview(scope.row)">
{{ TEXT.common.actions.view }}
</el-button>
<el-button link type="primary" size="small" @click="download(scope.row)">{{ TEXT.common.actions.download }}</el-button>
<el-button
v-if="canDelete(scope.row)"
@@ -40,6 +43,19 @@
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
<div v-if="previewError" class="preview-error">
{{ previewError }}
</div>
<template v-else>
<img v-if="previewType === 'image'" :src="previewUrl" class="preview-media" />
<iframe v-else-if="previewType === 'pdf'" :src="previewUrl" class="preview-frame" />
<div v-else class="preview-error">
{{ TEXT.common.messages.previewNotSupported }}
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
@@ -65,6 +81,11 @@ const loading = ref(false);
const auth = useAuthStore();
const study = useStudyStore();
const members = ref<any[]>([]);
const previewVisible = ref(false);
const previewUrl = ref("");
const previewType = ref<"image" | "pdf" | "other">("other");
const previewTitle = ref(TEXT.common.labels.preview);
const previewError = ref("");
const load = async () => {
if (!props.studyId || !props.entityId) return;
@@ -107,6 +128,25 @@ const download = (row: any) => {
window.open(url, "_blank");
};
const detectPreviewType = (row: any) => {
const mime = (row?.mime_type || row?.content_type || "").toLowerCase();
if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf";
return "other";
};
const preview = (row: any) => {
previewError.value = "";
previewType.value = detectPreviewType(row);
previewTitle.value = row?.filename || TEXT.common.labels.preview;
const token = localStorage.getItem("ctms_token");
previewUrl.value = row?.url || (token ? `/api/v1/attachments/${row.id}/download?token=${token}` : `/api/v1/attachments/${row.id}/download`);
if (previewType.value === "other") {
previewError.value = TEXT.common.messages.previewNotSupported;
}
previewVisible.value = true;
};
const canDelete = (row: any) => {
const userId = auth.user?.id;
const role = auth.user?.role;
@@ -141,4 +181,24 @@ onMounted(async () => {
align-items: center;
margin-bottom: 8px;
}
.preview-frame {
width: 100%;
height: 520px;
border: none;
}
.preview-media {
max-width: 100%;
max-height: 520px;
display: block;
margin: 0 auto;
}
.preview-error {
color: var(--ctms-text-secondary);
font-size: 13px;
padding: 12px 0;
text-align: center;
}
</style>
@@ -0,0 +1,322 @@
<template>
<div class="fee-attachments">
<el-card v-for="group in groups" :key="group.key" class="group-card">
<div class="group-header">
<div>
<div class="group-title">{{ group.label }}</div>
<div v-if="group.description" class="group-desc">{{ group.description }}</div>
</div>
<el-upload
:http-request="(options: any) => doUpload(group.key, options)"
:show-file-list="false"
:limit="1"
:disabled="readonly || isUploading(group.key)"
:auto-upload="true"
>
<el-button size="small" type="primary" :loading="isUploading(group.key)" :disabled="readonly">
{{ TEXT.common.actions.upload }}
</el-button>
</el-upload>
</div>
<el-progress
v-if="progressMap[group.key] && progressMap[group.key] < 100"
:percentage="progressMap[group.key]"
:stroke-width="6"
/>
<StateLoading v-if="loading" :rows="3" />
<template v-else>
<div v-if="(grouped[group.key] || []).length === 0" class="group-empty">
<StateEmpty :description="group.emptyText" />
</div>
<el-table v-else :data="grouped[group.key]" style="width: 100%">
<el-table-column prop="filename" :label="TEXT.common.labels.filename" min-width="160" />
<el-table-column :label="TEXT.common.labels.size" width="110">
<template #default="scope">{{ formatFileSize(scope.row.size || scope.row.file_size) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.uploader" width="140">
<template #default="scope">
{{ uploaderLabel(scope.row) }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="160">
<template #default="scope">
<el-button link type="primary" size="small" @click="preview(scope.row)">
{{ TEXT.common.actions.view }}
</el-button>
<el-button link type="primary" size="small" @click="download(scope.row)">
{{ TEXT.common.actions.download }}
</el-button>
<el-button
v-if="canDelete(scope.row)"
link
type="danger"
size="small"
@click="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
</el-table>
</template>
</el-card>
</div>
<el-dialog v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
<div v-if="previewError" class="preview-error">
{{ previewError }}
</div>
<template v-else>
<img v-if="previewType === 'image'" :src="previewUrl" class="preview-media" />
<iframe v-else-if="previewType === 'pdf'" :src="previewUrl" class="preview-frame" />
<div v-else class="preview-error">
{{ TEXT.common.messages.previewNotSupported }}
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { listFeeAttachments, uploadFeeAttachment, deleteFeeAttachment, getFeeAttachmentDownloadUrl } from "../../api/feeAttachments";
import { listMembers } from "../../api/members";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { formatFileSize } from "../attachments/attachmentUtils";
import { displayUser, getMemberDisplayName, getUserDisplayName } from "../../utils/display";
import StateEmpty from "../StateEmpty.vue";
import StateLoading from "../StateLoading.vue";
import { TEXT } from "../../locales";
interface AttachmentGroup {
key: string;
label: string;
description?: string;
emptyText?: string;
}
const props = defineProps<{
entityType: string;
entityId: string;
groups: AttachmentGroup[];
readonly?: boolean;
maxSizeMb?: number;
}>();
const loading = ref(false);
const grouped = reactive<Record<string, any[]>>({});
const progressMap = reactive<Record<string, number>>({});
const auth = useAuthStore();
const study = useStudyStore();
const members = ref<any[]>([]);
const previewVisible = ref(false);
const previewUrl = ref("");
const previewType = ref<"image" | "pdf" | "other">("other");
const previewTitle = ref(TEXT.common.labels.preview);
const previewError = ref("");
const isUploading = (key: string) => (progressMap[key] || 0) > 0 && (progressMap[key] || 0) < 100;
const loadMembers = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await listMembers(studyId, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const load = async () => {
if (!props.entityType || !props.entityId) return;
loading.value = true;
try {
const { data } = await listFeeAttachments(props.entityType, props.entityId);
const items = Array.isArray(data?.data) ? data?.data : [];
props.groups.forEach((group) => {
grouped[group.key] = items.filter((item: any) => item.file_type === group.key || item.fileType === group.key);
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
const uploaderLabel = (row: any) => {
const memberMap = members.value.reduce<Record<string, string>>((acc, cur) => {
const username = getMemberDisplayName(cur);
if (cur?.user_id && username) acc[cur.user_id] = username;
return acc;
}, {});
if (row?.uploaded_by && typeof row.uploaded_by === "object") {
return getUserDisplayName(row.uploaded_by) || row.uploaded_by.id || TEXT.common.fallback;
}
return displayUser(row.uploaded_by_id || row.uploaded_by, { members: memberMap });
};
const doUpload = async (fileType: string, options: any) => {
const file: File = options.file;
if (!file || props.readonly) return;
const maxSize = props.maxSizeMb ?? 50;
if (file.size > maxSize * 1024 * 1024) {
ElMessage.error(`${TEXT.common.messages.fileTooLarge} ${maxSize}MB`);
return;
}
progressMap[fileType] = 0;
try {
await uploadFeeAttachment(props.entityType, props.entityId, fileType, file, {
onUploadProgress: (evt: ProgressEvent) => {
if (evt.total) {
progressMap[fileType] = Math.round((evt.loaded / evt.total) * 100);
}
},
});
ElMessage.success(TEXT.common.messages.uploadSuccess);
await load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.uploadFailed);
} finally {
progressMap[fileType] = 0;
}
};
const detectPreviewType = (row: any) => {
const mime = (row?.mime_type || row?.content_type || "").toLowerCase();
if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf";
return "other";
};
const preview = (row: any) => {
previewError.value = "";
previewType.value = detectPreviewType(row);
previewTitle.value = row?.filename || TEXT.common.labels.preview;
previewUrl.value = row?.url || getFeeAttachmentDownloadUrl(row.id);
if (previewType.value === "other") {
previewError.value = TEXT.common.messages.previewNotSupported;
}
previewVisible.value = true;
};
const download = (row: any) => {
if (row?.url) {
window.open(row.url, "_blank");
return;
}
window.open(getFeeAttachmentDownloadUrl(row.id), "_blank");
};
const canDelete = (row: any) => {
if (props.readonly) return false;
const userId = auth.user?.id;
const role = auth.user?.role;
const projectRole = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
const ownerId =
row.uploaded_by_id || (row.uploaded_by && typeof row.uploaded_by === "object" ? row.uploaded_by.id : row.uploaded_by);
return userId === ownerId || role === "ADMIN" || projectRole === "PM";
};
const remove = async (row: any) => {
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteFeeAttachment(row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
watch(
() => [props.entityType, props.entityId],
() => {
load();
}
);
onMounted(async () => {
await loadMembers();
load();
});
</script>
<style scoped>
.fee-attachments {
display: flex;
flex-direction: column;
gap: 12px;
}
.group-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
gap: 8px;
}
.group-title {
font-size: 14px;
font-weight: 600;
color: var(--ctms-text-main);
}
.group-desc {
font-size: 12px;
color: var(--ctms-text-secondary);
margin-top: 4px;
}
.group-empty {
padding: 0;
}
/* Compact Empty State Override */
.group-empty :deep(.state) {
padding: 16px 0 !important;
min-height: auto !important;
flex-direction: row;
gap: 8px;
}
.group-empty :deep(.state-icon) {
font-size: 20px !important;
margin-bottom: 0 !important;
}
.group-empty :deep(.state-title) {
font-size: 13px !important;
font-weight: normal !important;
margin: 0 !important;
}
.group-empty :deep(.state-desc) {
display: none; /* Hide description in compact mode if desired, or keep it small */
}
.preview-frame {
width: 100%;
height: 520px;
border: none;
}
.preview-media {
max-width: 100%;
max-height: 520px;
display: block;
margin: 0 auto;
}
.preview-error {
color: var(--ctms-text-secondary);
font-size: 13px;
padding: 12px 0;
text-align: center;
}
</style>