361 lines
11 KiB
Vue
361 lines
11 KiB
Vue
<template>
|
|
<div class="page">
|
|
<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="statusLabel(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="categoryLabel(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" />
|
|
<PermissionAction action="finance.create">
|
|
<el-button type="primary" @click="showForm = true">新建费用</el-button>
|
|
</PermissionAction>
|
|
</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">
|
|
<template #default="scope">
|
|
{{ categoryLabel(scope.row.category) }}
|
|
</template>
|
|
</el-table-column>
|
|
<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">
|
|
<template #default="scope">{{ displayDate(scope.row.occur_date) }}</template>
|
|
</el-table-column>
|
|
<el-table-column prop="status" label="状态" width="120">
|
|
<template #default="scope">
|
|
<el-tag :type="statusTag(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="submitted_at" label="提交时间" width="180">
|
|
<template #default="scope">{{ displayDateTime(scope.row.submitted_at) }}</template>
|
|
</el-table-column>
|
|
<el-table-column prop="approved_at" label="审批时间" width="180">
|
|
<template #default="scope">{{ displayDateTime(scope.row.approved_at) }}</template>
|
|
</el-table-column>
|
|
<el-table-column prop="paid_at" label="支付时间" width="180">
|
|
<template #default="scope">{{ displayDateTime(scope.row.paid_at) }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="操作" width="220">
|
|
<template #default="scope">
|
|
<el-button
|
|
v-if="showAction(scope.row, 'approve')"
|
|
type="primary"
|
|
size="small"
|
|
@click.stop="onApprove(scope.row)"
|
|
>
|
|
审批
|
|
</el-button>
|
|
<el-button
|
|
v-if="showAction(scope.row, 'reject')"
|
|
type="danger"
|
|
size="small"
|
|
@click.stop="onReject(scope.row)"
|
|
>
|
|
驳回
|
|
</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</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">
|
|
import { computed, onMounted, ref } from "vue";
|
|
import { useRouter } from "vue-router";
|
|
import { ElMessage, ElMessageBox } from "element-plus";
|
|
import FinanceSummary from "../components/FinanceSummary.vue";
|
|
import FinanceForm from "../components/FinanceForm.vue";
|
|
import { fetchFinanceItems, fetchFinanceSummary, changeFinanceStatus } from "../api/finance";
|
|
import { useStudyStore } from "../store/study";
|
|
import { useAuthStore } from "../store/auth";
|
|
import PermissionAction from "../components/PermissionAction.vue";
|
|
import { usePermission } from "../utils/permission";
|
|
import { financeMachine, getAvailableActions } from "../state-machine";
|
|
import { evaluateAction } from "../guards/actionGuard";
|
|
import { logAudit } from "../audit";
|
|
import { displayDate, displayDateTime } from "../utils/display";
|
|
|
|
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 { can } = usePermission();
|
|
const canCreate = computed(() => can("finance.create"));
|
|
const permissionMap: Record<string, string> = {
|
|
approve: "finance.approve",
|
|
reject: "finance.approve",
|
|
};
|
|
|
|
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 statusLabel = (status?: string) =>
|
|
({
|
|
DRAFT: "草稿",
|
|
SUBMITTED: "已提交",
|
|
APPROVED: "已审批",
|
|
REJECTED: "已驳回",
|
|
PAID: "已支付",
|
|
}[status || ""] || status || "");
|
|
|
|
const categoryLabel = (c?: string) =>
|
|
({
|
|
SITE_FEE: "中心费用",
|
|
SUBJECT_STIPEND: "受试者补偿",
|
|
TRAVEL: "差旅",
|
|
OTHER: "其他",
|
|
}[c || ""] || c || "");
|
|
|
|
const showAction = (row: any, key: string) => {
|
|
const actions = getAvailableActions(financeMachine, row.status).filter((a) => a.key === key);
|
|
if (!actions.length) return false;
|
|
const decision = evaluateAction({
|
|
actorRole: auth.user?.role || null,
|
|
requiredPermission: permissionMap[key],
|
|
stateMachine: financeMachine,
|
|
currentState: row.status,
|
|
actionKey: key,
|
|
});
|
|
return decision.allowed;
|
|
};
|
|
|
|
const doChange = async (row: any, status: string, reason?: string) => {
|
|
if (!study.currentStudy) return false;
|
|
try {
|
|
await changeFinanceStatus(study.currentStudy.id, row.id, { status, reject_reason: reason });
|
|
return true;
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || "操作失败");
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const onApprove = async (row: any) => {
|
|
const action = getAvailableActions(financeMachine, row.status).find((a) => a.key === "approve");
|
|
const decision = evaluateAction({
|
|
actorRole: auth.user?.role || null,
|
|
requiredPermission: permissionMap.approve,
|
|
stateMachine: financeMachine,
|
|
currentState: row.status,
|
|
actionKey: "approve",
|
|
});
|
|
if (!decision.allowed) {
|
|
ElMessage.warning(decision.reason || "当前不可执行该操作");
|
|
logAudit(decision.auditType, {
|
|
targetId: row.id,
|
|
targetName: row.title,
|
|
reason: decision.reason,
|
|
severity: decision.severity,
|
|
});
|
|
return;
|
|
}
|
|
if (action?.confirm) {
|
|
const ok = await ElMessageBox.confirm(action.confirmText || "确认审批通过?", "提示").catch(() => null);
|
|
if (!ok) return;
|
|
}
|
|
const ok = await doChange(row, action?.to || "APPROVED");
|
|
logAudit("FINANCE_APPROVED", {
|
|
targetId: row.id,
|
|
targetName: row.title,
|
|
before: { status: row.status },
|
|
after: { status: action?.to || "APPROVED" },
|
|
severity: ok ? "normal" : "warning",
|
|
});
|
|
if (ok) {
|
|
ElMessage.success("已审批");
|
|
loadList();
|
|
}
|
|
};
|
|
|
|
const onReject = async (row: any) => {
|
|
const action = getAvailableActions(financeMachine, row.status).find((a) => a.key === "reject");
|
|
const decision = evaluateAction({
|
|
actorRole: auth.user?.role || null,
|
|
requiredPermission: permissionMap.reject,
|
|
stateMachine: financeMachine,
|
|
currentState: row.status,
|
|
actionKey: "reject",
|
|
});
|
|
if (!decision.allowed) {
|
|
ElMessage.warning(decision.reason || "当前不可执行该操作");
|
|
logAudit(decision.auditType, {
|
|
targetId: row.id,
|
|
targetName: row.title,
|
|
reason: decision.reason,
|
|
severity: decision.severity,
|
|
});
|
|
return;
|
|
}
|
|
if (action?.confirm) {
|
|
const ok = await ElMessageBox.confirm(action.confirmText || "确认驳回?", "提示", {
|
|
type: action.danger ? "warning" : "info",
|
|
}).catch(() => null);
|
|
if (!ok) return;
|
|
}
|
|
const reason = await ElMessageBox.prompt("请输入驳回原因", "驳回", {
|
|
confirmButtonText: "确定",
|
|
cancelButtonText: "取消",
|
|
}).catch(() => null);
|
|
if (!reason || !reason.value) return;
|
|
const ok = await doChange(row, action?.to || "REJECTED", reason.value);
|
|
logAudit("FINANCE_REJECTED", {
|
|
targetId: row.id,
|
|
targetName: row.title,
|
|
before: { status: row.status },
|
|
after: { status: action?.to || "REJECTED" },
|
|
severity: ok ? "normal" : "warning",
|
|
reason: reason.value,
|
|
});
|
|
if (ok) {
|
|
ElMessage.success("已驳回");
|
|
loadList();
|
|
}
|
|
};
|
|
|
|
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>
|