Files
gao-customer-h5/src/pages/salesOrder/FundRecordDetail.vue
T
2026-09-05 18:10:48 +08:00

414 lines
12 KiB
Vue

<template>
<div v-can="permissionCodes.fundRecord.read" class="package-record-detail">
<template v-if="record">
<section class="hero" v-entrance-transition>
<div class="header">
<span class="eyebrow" v-text="'资金流水'" />
<t-tag :content="getRecordStatusText(record)" :theme="getRecordStatusTheme(record)" variant="light-outline" />
</div>
<h1 class="title" v-text="getFundOperationText(record.operationType)" />
<div class="amount">
<span class="red">¥</span>
<strong class="value red text-bold" v-text="formatPackageAmount(record.amount)" />
</div>
</section>
<t-cell-group title="订单信息" theme="card" v-entrance-transition="70">
<t-cell title="订单总价" :note="formatPackageAmount(record.salesOrderAmount)" />
<t-cell title="剩余待收款(流水快照)" :note="formatPackageAmount(record.salesOrderUnpaidAmountSnapshot)" />
</t-cell-group>
<t-cell-group title="资金记录" theme="card" v-entrance-transition="140">
<t-cell title="操作类型" :note="getFundOperationText(record.operationType)" />
<t-cell title="状态">
<template #note>
<t-tag
:content="getRecordStatusText(record)"
:theme="getRecordStatusTheme(record)"
variant="light-outline"
/>
</template>
</t-cell>
<t-cell title="金额" :note="formatPackageAmount(record.amount)" />
<t-cell
v-if="record.sourceRecordId"
title="冲正流水"
arrow
@click="toSourceRecord"
/>
<t-cell title="备注" :note="Text.display(record.remark)" />
<t-cell v-if="record.reason" title="拒绝/撤销原因" :note="Text.display(record.reason)" />
<t-cell
title="销售订单详情"
:arrow="!!record.bizId && canReadSalesOrder"
@click="toSalesOrder"
/>
<div class="photo-wrap bg-white">
<photo-scroller
:model-value="photoURLList"
:photo-items="photoItems"
title="业务照片"
:show-add="false"
:show-remove="false"
:show-viewer-actions="false"
/>
</div>
</t-cell-group>
<t-cell-group theme="card" v-entrance-transition="210">
<t-cell
title="发起人"
:note="record.operator?.name || record.operator?.nick || '未知'"
/>
<t-cell title="发起时间">
<template #note>
<pass-time-label :timestamp="record.occurredAt" underline />
</template>
</t-cell>
<t-cell
title="处理人"
:note="record.verifier?.name || record.verifier?.nick || '未处理'"
/>
<t-cell title="处理时间">
<template #note>
<pass-time-label :timestamp="record.verifiedAt" underline />
</template>
</t-cell>
</t-cell-group>
<section
v-if="isPendingReceive || canRevoke"
class="actions"
v-entrance-transition="280"
>
<div class="pair">
<t-button
v-if="canVerifyReceive"
theme="primary"
content="核销收款"
@click="verifyReceive"
/>
<t-button
v-if="canVerifyReceive"
theme="danger"
content="拒绝收款"
@click="openReasonDialog('reject')"
/>
</div>
<t-button
v-if="canCancelReceive"
theme="default"
content="撤销收款"
@click="openReasonDialog('cancel')"
/>
<t-button
v-if="canRevoke"
theme="danger"
content="资金冲正"
@click="toRevoke"
/>
</section>
</template>
<t-empty v-else :description="isLoading ? '正在加载资金记录' : '资金记录不存在'" />
<t-dialog
v-model:visible="reasonDialogVisible"
:title="reasonTitle"
cancel-btn="取消"
:confirm-btn="reasonActionText"
@cancel="reasonDialogVisible = false"
@confirm="submitReason"
>
<t-textarea v-model.trim="reasonText" borderless autosize placeholder="请输入原因" />
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { DialogPlugin } from "tdesign-mobile-vue";
import { PassTimeLabel, Text } from "timi-web";
import { useUserStore } from "timi-web-api";
import SalesOrderAPI from "@/api/SalesOrderAPI";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { FundRecord, FundRecordStatus } from "@/types/SalesOrder";
import type { PhotoItem } from "@/types/Photo";
import { getAttachmentReadURL } from "@/utils/attachment";
import { getErrorMessage } from "@/utils/error";
import { formatPackageAmount } from "@/utils/package";
import { getFundOperationText, getFundStatusText } from "@/utils/salesOrder";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
type ReasonAction = "reject" | "cancel";
defineOptions({ name: "FundRecordDetail" });
// 公共变量
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const isOperating = ref(false);
// ---------- 资金记录 ----------
const record = ref<FundRecord>();
const isLoading = ref(false);
const isPendingReceive = computed(() => record.value?.operationType === "RECEIVE" && record.value.status === "PENDING");
const canVerifyReceive = computed(() => isPendingReceive.value && userStore.hasPermissions(permissionCodes.fundReceive.verify));
const canCancelReceive = computed(() => isPendingReceive.value && userStore.hasPermissions(permissionCodes.fundReceive.create));
const canRevoke = computed(() => record.value?.status === "VERIFIED"
&& record.value.operationType !== "REVOKE"
&& !record.value.revokeRecordId
&& userStore.hasPermissions(permissionCodes.fund.revoke));
function getStatusTheme(status?: FundRecordStatus): "success" | "warning" | "danger" | "default" {
return status === "VERIFIED" ? "success" : status === "PENDING" ? "warning" : status === "REJECTED" ? "danger" : "default";
}
function getRecordStatusText(recordValue: FundRecord): string {
return recordValue.revokeRecordId ? "已被冲正" : getFundStatusText(recordValue.status);
}
function getRecordStatusTheme(recordValue: FundRecord): "success" | "warning" | "danger" | "default" {
return recordValue.revokeRecordId ? "danger" : getStatusTheme(recordValue.status);
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
record.value = await SalesOrderAPI.fundRecordDetail(id);
} catch (error) {
Message.error(getErrorMessage(error, "资金记录加载失败"));
} finally {
isLoading.value = false;
}
}
// ---------- 订单详情 ----------
const canReadSalesOrder = computed(() => userStore.hasPermissions(permissionCodes.salesOrder.read));
async function toSalesOrder(): Promise<void> {
if (!record.value?.bizId || !canReadSalesOrder.value) {
return;
}
await router.push({
path: `/management/sales-orders/${record.value.bizId}`,
query: { transitionDepth: String(getCurrentRouteDepth() + 1) }
});
}
// ---------- 原资金记录 ----------
async function toSourceRecord(): Promise<void> {
if (!record.value?.sourceRecordId) {
return;
}
await router.push({
path: `/management/fund-records/${record.value.sourceRecordId}`,
query: { transitionDepth: String(getCurrentRouteDepth() + 1) }
});
}
function getCurrentRouteDepth(): number {
const transitionDepth = Array.isArray(route.query.transitionDepth)
? route.query.transitionDepth[0]
: route.query.transitionDepth;
const depth = Number(transitionDepth);
if (transitionDepth && Number.isInteger(depth) && 0 <= depth && depth <= 20) {
return depth;
}
return Number(route.meta.depth ?? 0);
}
// ---------- 业务照片 ----------
const photoItems = computed<PhotoItem[]>(() => (record.value?.attachmentList || [])
.filter(item => !!item.id)
.map(item => ({
attachment: item,
attachType: item.attachType || "PHOTO",
url: getAttachmentReadURL(item.id || "")
})));
const photoURLList = computed(() => photoItems.value.map(item => item.url));
// ---------- 收款操作 ----------
async function refreshRelatedPackage(): Promise<void> {
if (record.value?.bizId) {
await SalesOrderAPI.detail(record.value.bizId);
}
}
async function verifyReceive(): Promise<void> {
if (!record.value?.id || !canVerifyReceive.value || isOperating.value) {
return;
}
const dialog = DialogPlugin.confirm({
title: "核销收款",
content: "核销后该金额才会计入套餐的已收款。确定继续?",
cancelBtn: "取消",
confirmBtn: "核销",
onCancel() {
dialog.destroy();
},
onConfirm: async () => {
isOperating.value = true;
try {
await SalesOrderAPI.verifyPayment(record.value!.id!);
await refreshRelatedPackage();
Message.success("收款已核销");
await loadData();
} catch (error) {
Message.error(getErrorMessage(error, "收款核销失败,请刷新后确认状态"));
} finally {
isOperating.value = false;
dialog.destroy();
}
}
});
}
// ---------- 收款原因 ----------
const reasonDialogVisible = ref(false);
const reasonText = ref("");
const reasonAction = ref<ReasonAction>("reject");
const reasonTitle = computed(() => reasonAction.value === "reject" ? "拒绝收款" : "撤销收款");
const reasonActionText = computed(() => reasonAction.value === "reject" ? "拒绝" : "撤销");
function openReasonDialog(action: ReasonAction): void {
if (!isPendingReceive.value || isOperating.value) {
return;
}
reasonAction.value = action;
reasonText.value = "";
reasonDialogVisible.value = true;
}
async function submitReason(): Promise<void> {
if (!record.value?.id || !reasonText.value.trim() || isOperating.value) {
Message.warning("请输入原因");
return;
}
isOperating.value = true;
try {
if (reasonAction.value === "reject") {
await SalesOrderAPI.rejectPayment({ fundRecordId: record.value.id, reason: reasonText.value.trim() });
} else {
await SalesOrderAPI.cancelPayment({ fundRecordId: record.value.id, reason: reasonText.value.trim() });
}
reasonDialogVisible.value = false;
await refreshRelatedPackage();
Message.success(reasonAction.value === "reject" ? "收款已拒绝" : "收款已撤销");
await loadData();
} catch (error) {
Message.error(getErrorMessage(error, "收款状态操作失败,请刷新后确认状态"));
} finally {
isOperating.value = false;
}
}
// ---------- 资金冲正 ----------
async function toRevoke(): Promise<void> {
if (record.value?.id && canRevoke.value) {
await router.push(`/management/fund-records/${record.value.id}/revoke`);
}
}
// ---------- 生命周期 ----------
onMounted(loadData);
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-record-detail {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.hero {
margin: 0 1rem;
padding: 1rem;
background: linear-gradient(135deg, #eff6ff, #f0fdf4);
box-shadow: var(--td-shadow-1);
border-radius: 1.25rem;
.header {
display: flex;
align-items: center;
justify-content: space-between;
.eyebrow {
color: #2563eb;
display: block;
font-size: .6875rem;
font-weight: 700;
letter-spacing: .08em;
}
}
.title {
margin: .5rem 0;
font-size: 1.2rem;
line-height: 1.35;
}
.amount {
display: block;
margin-top: .75rem;
line-height: 1.2;
.value {
font-size: 1.5rem;
}
}
}
.photo-wrap {
padding: 1rem;
}
.actions {
--td-button-border-radius: 1rem;
gap: .5rem;
padding: 0 1rem;
display: flex;
flex-wrap: wrap;
.pair {
gap: .5rem;
width: 100%;
display: flex;
.t-button {
width: calc(50% - .25rem);
margin: 0;
flex: 1 1 0;
}
}
.t-button {
flex: 1 1 auto;
}
}
}
:global(html.theme-dark) .package-record-detail .hero {
background: linear-gradient(135deg, rgba(37, 99, 235, .2), rgba(15, 118, 110, .18));
.eyebrow {
color: #93c5fd;
}
.amount {
color: #99f6e4;
}
}
</style>