Step F9:费用管理(Finance)前端

This commit is contained in:
Cheng Zhou
2025-12-17 14:07:23 +08:00
parent 25fa11b9cc
commit a10590ef8d
20 changed files with 661 additions and 2 deletions
+64
View File
@@ -0,0 +1,64 @@
import { apiGet, apiPatch, apiPost } from "./axios";
import type { AxiosResponse } from "axios";
import type { ApiListResponse } from "../types/api";
export interface FinanceItem {
id: string;
study_id: string;
site_id?: string | null;
subject_id?: string | null;
visit_id?: string | null;
category: string;
title: string;
description?: string | null;
currency: string;
amount: number | string;
occur_date: string;
status: string;
submitted_at?: string | null;
approved_at?: string | null;
rejected_at?: string | null;
paid_at?: string | null;
approver_id?: string | null;
payer_id?: string | null;
reject_reason?: string | null;
created_by: string;
created_at: string;
updated_at?: string | null;
}
export interface FinanceSummary {
total_amount: number | string;
approved_amount: number | string;
paid_amount: number | string;
submitted_amount?: number | string;
count_total: number;
count_paid: number;
}
export const fetchFinanceItems = (
studyId: string,
params?: Record<string, any>
): Promise<AxiosResponse<ApiListResponse<FinanceItem>>> =>
apiGet(`/api/v1/studies/${studyId}/finance/items/`, { params });
export const fetchFinanceItem = (studyId: string, itemId: string) =>
apiGet<FinanceItem>(`/api/v1/studies/${studyId}/finance/items/${itemId}`);
export const createFinanceItem = (studyId: string, payload: Record<string, any>) =>
apiPost<FinanceItem>(`/api/v1/studies/${studyId}/finance/items`, payload);
export const updateFinanceItem = (studyId: string, itemId: string, payload: Record<string, any>) =>
apiPatch<FinanceItem>(`/api/v1/studies/${studyId}/finance/items/${itemId}`, payload);
export const changeFinanceStatus = (
studyId: string,
itemId: string,
payload: { status: string; reject_reason?: string }
) => apiPost<FinanceItem>(`/api/v1/studies/${studyId}/finance/items/${itemId}/status`, payload);
export const fetchFinanceSummary = (studyId: string, params?: Record<string, any>) =>
apiGet<FinanceSummary | ApiListResponse<FinanceSummary>>(
`/api/v1/studies/${studyId}/finance/summary`,
{ params }
);
+138
View File
@@ -0,0 +1,138 @@
<template>
<el-dialog :model-value="modelValue" title="费用" width="520px" @close="close">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="类别" prop="category">
<el-select v-model="form.category" placeholder="请选择">
<el-option v-for="c in categories" :key="c" :label="c" :value="c" />
</el-select>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="金额" prop="amount">
<el-input-number v-model="form.amount" :min="0.01" :step="100" :precision="2" controls-position="right" />
</el-form-item>
<el-form-item label="币种">
<el-input v-model="form.currency" />
</el-form-item>
<el-form-item label="发生日期" prop="occur_date">
<el-date-picker v-model="form.occur_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="中心ID">
<el-input v-model="form.site_id" placeholder="可选" />
</el-form-item>
<el-form-item label="受试者ID">
<el-input v-model="form.subject_id" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { useStudyStore } from "../store/study";
import { createFinanceItem, updateFinanceItem } from "../api/finance";
const props = defineProps<{ modelValue: boolean; item?: any }>();
const emit = defineEmits(["update:modelValue", "success"]);
const study = useStudyStore();
const formRef = ref<FormInstance>();
const submitting = ref(false);
const categories = ["SITE_FEE", "SUBJECT_STIPEND", "TRAVEL", "OTHER"];
const form = reactive({
category: "SITE_FEE",
title: "",
description: "",
amount: 0,
currency: "CNY",
occur_date: "",
site_id: "",
subject_id: "",
});
const rules: FormRules = {
category: [{ required: true, message: "请选择类别", trigger: "change" }],
title: [{ required: true, message: "请输入标题", trigger: "blur" }],
amount: [{ required: true, message: "请输入金额", trigger: "blur" }],
occur_date: [{ required: true, message: "请选择日期", trigger: "change" }],
};
const isEdit = computed(() => !!props.item);
const reset = () => {
form.category = "SITE_FEE";
form.title = "";
form.description = "";
form.amount = 0;
form.currency = "CNY";
form.occur_date = "";
form.site_id = "";
form.subject_id = "";
};
watch(
() => props.item,
(val) => {
if (val) {
form.category = val.category;
form.title = val.title;
form.description = val.description || "";
form.amount = Number(val.amount) || 0;
form.currency = val.currency || "CNY";
form.occur_date = val.occur_date;
form.site_id = val.site_id || "";
form.subject_id = val.subject_id || "";
} else {
reset();
}
},
{ immediate: true }
);
const close = () => emit("update:modelValue", false);
const onSubmit = async () => {
if (!study.currentStudy || !formRef.value) return;
await formRef.value.validate();
if (form.amount <= 0) {
ElMessageBox.alert("金额必须大于 0");
return;
}
submitting.value = true;
try {
const payload = {
category: form.category,
title: form.title,
description: form.description || null,
amount: Number(form.amount),
currency: form.currency,
occur_date: form.occur_date,
site_id: form.site_id ? form.site_id : null,
subject_id: form.subject_id ? form.subject_id : null,
};
if (isEdit.value && props.item) {
await updateFinanceItem(study.currentStudy.id, props.item.id, payload);
} else {
await createFinanceItem(study.currentStudy.id, payload);
}
ElMessage.success("提交成功");
emit("success");
close();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
submitting.value = false;
}
};
</script>
@@ -0,0 +1,91 @@
<template>
<div class="actions">
<el-button
v-if="item.status === 'DRAFT' && canSubmit"
type="primary"
size="small"
:loading="loading"
@click="change('SUBMITTED')"
>
提交
</el-button>
<el-button
v-if="item.status === 'SUBMITTED' && canApprove"
type="success"
size="small"
:loading="loading"
@click="change('APPROVED')"
>
审批通过
</el-button>
<el-button
v-if="item.status === 'SUBMITTED' && canApprove"
type="danger"
size="small"
:loading="loading"
@click="reject"
>
驳回
</el-button>
<el-button
v-if="item.status === 'APPROVED' && canPay"
type="warning"
size="small"
:loading="loading"
@click="change('PAID')"
>
确认支付
</el-button>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, ref } from "vue";
import { changeFinanceStatus } from "../api/finance";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
const props = defineProps<{ item: any }>();
const emit = defineEmits(["success"]);
const study = useStudyStore();
const auth = useAuthStore();
const loading = ref(false);
const role = computed(() => auth.user?.role);
const canSubmit = computed(() => role.value === "ADMIN" || role.value === "PM" || role.value === "CRA");
const canApprove = computed(() => role.value === "ADMIN" || role.value === "PM");
const canPay = canApprove;
const change = async (status: string, reject_reason?: string) => {
if (!study.currentStudy) return;
loading.value = true;
try {
await changeFinanceStatus(study.currentStudy.id, props.item.id, { status, reject_reason });
ElMessage.success("状态已更新");
emit("success");
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败");
} finally {
loading.value = false;
}
};
const reject = async () => {
const reason = await ElMessageBox.prompt("请输入驳回原因", "驳回", {
confirmButtonText: "确定",
cancelButtonText: "取消",
}).catch(() => null);
if (!reason || !reason.value) return;
change("REJECTED", reason.value);
};
</script>
<style scoped>
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,65 @@
<template>
<el-card>
<div class="summary">
<div class="item">
<div class="label">总金额全部</div>
<div class="value">{{ format(summary?.total_amount) }}</div>
</div>
<div class="item">
<div class="label">待审批金额</div>
<div class="value">{{ format(summary?.submitted_amount) }}</div>
</div>
<div class="item">
<div class="label">已审批金额</div>
<div class="value">{{ format(summary?.approved_amount) }}</div>
</div>
<div class="item">
<div class="label">已支付金额</div>
<div class="value">{{ format(summary?.paid_amount) }}</div>
</div>
<div class="item">
<div class="label">条目数</div>
<div class="value">{{ summary?.count_total ?? 0 }}</div>
</div>
<div class="item">
<div class="label">已支付条目</div>
<div class="value">{{ summary?.count_paid ?? 0 }}</div>
</div>
</div>
</el-card>
</template>
<script setup lang="ts">
import type { FinanceSummary } from "../api/finance";
defineProps<{
summary?: FinanceSummary;
}>();
const format = (num?: number | string) => {
const n = Number(num);
return Number.isFinite(n) ? n.toFixed(2) : "0.00";
};
</script>
<style scoped>
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.item {
background: #f5f7fa;
border-radius: 6px;
padding: 12px;
}
.label {
color: #606266;
font-size: 12px;
}
.value {
font-weight: 600;
margin-top: 4px;
font-size: 16px;
}
</style>
+7
View File
@@ -20,6 +20,7 @@ import ImpBatches from "../views/ImpBatches.vue";
import ImpInventory from "../views/ImpInventory.vue";
import ImpTransactions from "../views/ImpTransactions.vue";
import Finance from "../views/Finance.vue";
import FinanceDetail from "../views/FinanceDetail.vue";
import Faq from "../views/Faq.vue";
import Issues from "../views/Issues.vue";
import IssueDetail from "../views/IssueDetail.vue";
@@ -145,6 +146,12 @@ const routes: RouteRecordRaw[] = [
component: Finance,
meta: { title: "费用", requiresStudy: true },
},
{
path: "study/finance/:itemId",
name: "StudyFinanceDetail",
component: FinanceDetail,
meta: { title: "费用详情", requiresStudy: true },
},
{
path: "study/faq",
name: "StudyFaq",
+185 -2
View File
@@ -1,13 +1,196 @@
<template>
<div class="page">
<el-card><h2>费用管理</h2></el-card>
<FinanceSummary :summary="summary" class="mb-12" />
<el-card class="mb-12">
<div class="filters">
<el-select v-model="filters.status" placeholder="状态" clearable @change="loadList">
<el-option v-for="s in statuses" :key="s" :label="s" :value="s" />
</el-select>
<el-select v-model="filters.category" placeholder="类别" clearable @change="loadList">
<el-option v-for="c in categories" :key="c" :label="c" :value="c" />
</el-select>
<el-date-picker
v-model="filters.date_from"
type="date"
placeholder="开始日期"
value-format="YYYY-MM-DD"
@change="loadList"
/>
<el-date-picker
v-model="filters.date_to"
type="date"
placeholder="结束日期"
value-format="YYYY-MM-DD"
@change="loadList"
/>
<div class="spacer" />
<el-button type="primary" v-if="canCreate" @click="showForm = true">新建费用</el-button>
</div>
</el-card>
<el-card>
<el-table :data="items" v-loading="loading" @row-click="goDetail" style="width: 100%">
<el-table-column prop="title" label="标题" min-width="160" />
<el-table-column prop="category" label="类别" width="140" />
<el-table-column prop="amount" label="金额" width="140" align="right">
<template #default="scope">
{{ formatAmount(scope.row.amount) }}
</template>
</el-table-column>
<el-table-column prop="occur_date" label="发生日期" width="140" />
<el-table-column prop="status" label="状态" width="120">
<template #default="scope">
<el-tag :type="statusTag(scope.row.status)">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="submitted_at" label="提交时间" width="160" />
<el-table-column prop="approved_at" label="审批时间" width="160" />
<el-table-column prop="paid_at" label="支付时间" width="160" />
</el-table>
<el-pagination
class="pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
:total="total"
@current-change="onPageChange"
/>
</el-card>
<FinanceForm v-model="showForm" @success="loadList" />
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import FinanceSummary from "../components/FinanceSummary.vue";
import FinanceForm from "../components/FinanceForm.vue";
import { fetchFinanceItems, fetchFinanceSummary } from "../api/finance";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const items = ref<any[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = 10;
const loading = ref(false);
const summary = ref<any>(null);
const filters = ref({
status: "",
category: "",
date_from: "",
date_to: "",
});
const statuses = ["DRAFT", "SUBMITTED", "APPROVED", "REJECTED", "PAID"];
const categories = ["SITE_FEE", "SUBJECT_STIPEND", "TRAVEL", "OTHER"];
const showForm = ref(false);
const canCreate = computed(() => {
const role = auth.user?.role;
return role === "ADMIN" || role === "PM" || role === "CRA";
});
const formatAmount = (num?: number | string) => {
const n = Number(num);
return Number.isFinite(n) ? n.toFixed(2) : "0.00";
};
const statusTag = (status?: string) => {
switch (status) {
case "DRAFT":
return "info";
case "SUBMITTED":
return "warning";
case "APPROVED":
return "success";
case "REJECTED":
return "danger";
case "PAID":
return "success";
default:
return "info";
}
};
const loadSummary = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchFinanceSummary(study.currentStudy.id);
summary.value = Array.isArray(data) ? data[0] : data;
} catch (e: any) {
/* ignore summary errors */
}
};
const loadList = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
if (filters.value.status) params.status = filters.value.status;
if (filters.value.category) params.category = filters.value.category;
if (filters.value.date_from) params.date_from = filters.value.date_from;
if (filters.value.date_to) params.date_to = filters.value.date_to;
const { data } = await fetchFinanceItems(study.currentStudy.id, params);
if (Array.isArray(data)) {
items.value = data;
total.value = data.length;
} else {
items.value = data.items || [];
total.value = data.total || 0;
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "费用列表加载失败");
} finally {
loading.value = false;
}
};
const onPageChange = (p: number) => {
page.value = p;
loadList();
};
const goDetail = (row: any) => {
router.push(`/study/finance/${row.id}`);
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await loadSummary();
await loadList();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.filters {
display: flex;
gap: 8px;
align-items: center;
}
.spacer {
flex: 1;
}
.pagination {
margin-top: 12px;
text-align: right;
}
.mb-12 {
margin-bottom: 12px;
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<template>
<div class="page">
<el-card v-loading="loading">
<div class="header">
<div>
<h3>{{ item?.title }}</h3>
<el-tag :type="statusTag(item?.status)">{{ item?.status }}</el-tag>
</div>
<div class="header-actions" v-if="item">
<el-button v-if="canEditDraft" size="small" @click="showEdit = true">编辑</el-button>
<FinanceStatusActions :item="item" @success="loadItem" />
</div>
</div>
<el-descriptions :column="2" border class="mt-12" v-if="item">
<el-descriptions-item label="类别">{{ item.category }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ formatAmount(item.amount) }} {{ item.currency }}</el-descriptions-item>
<el-descriptions-item label="发生日期">{{ item.occur_date }}</el-descriptions-item>
<el-descriptions-item label="中心">{{ item.site_id || "-" }}</el-descriptions-item>
<el-descriptions-item label="受试者">{{ item.subject_id || "-" }}</el-descriptions-item>
<el-descriptions-item label="状态时间">
提交: {{ item.submitted_at || "-" }} / 审批: {{ item.approved_at || "-" }} /
支付: {{ item.paid_at || "-" }}
</el-descriptions-item>
<el-descriptions-item label="驳回原因">{{ item.reject_reason || "-" }}</el-descriptions-item>
<el-descriptions-item label="描述">{{ item.description || "-" }}</el-descriptions-item>
</el-descriptions>
</el-card>
<FinanceForm v-model="showEdit" :item="item" @success="loadItem" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import FinanceStatusActions from "../components/FinanceStatusActions.vue";
import FinanceForm from "../components/FinanceForm.vue";
import { fetchFinanceItem } from "../api/finance";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
const study = useStudyStore();
const auth = useAuthStore();
const route = useRoute();
const item = ref<any>(null);
const loading = ref(false);
const showEdit = ref(false);
const canEditDraft = computed(() => {
const role = auth.user?.role;
return item.value?.status === "DRAFT" && (role === "ADMIN" || role === "PM" || role === "CRA");
});
const loadItem = async () => {
if (!study.currentStudy) return;
const id = route.params.itemId as string;
loading.value = true;
try {
const { data } = await fetchFinanceItem(study.currentStudy.id, id);
item.value = data;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loading.value = false;
}
};
const statusTag = (status?: string) => {
switch (status) {
case "DRAFT":
return "info";
case "SUBMITTED":
return "warning";
case "APPROVED":
return "success";
case "REJECTED":
return "danger";
case "PAID":
return "success";
default:
return "info";
}
};
const formatAmount = (num?: number | string) => {
const n = Number(num);
return Number.isFinite(n) ? n.toFixed(2) : "0.00";
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await loadItem();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.mt-12 {
margin-top: 12px;
}
</style>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.