Files
jiapuapp/pages/profile/earnings.vue
T

446 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="earnings-page">
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="收益与提现" /></view>
<view class="earnings-content page-layer">
<AppLoading
v-if="earningsState === 'loading'"
text="正在读取收益"
description="请稍候。"
/>
<view v-else-if="earningsState === 'error'" class="state-card">
<text>收益暂时无法读取</text>
<text>{{ pageError }}</text>
<AppButton block label="重新读取" @click="loadAll" />
</view>
<template v-else>
<text v-if="operationNotice" class="operation-notice" role="status">{{ operationNotice }}</text>
<view class="summary-panel">
<text>可提现收益</text>
<text>¥{{ summary.availableAmount }}</text>
<view class="summary-row">
<text>处理中</text>
<text>¥{{ summary.frozenAmount }}</text>
</view>
<view class="summary-row">
<text>最低提现金额</text>
<text>{{ minimumWithdrawalLabel }}</text>
</view>
<text v-if="!summary.withdrawalEnabled" class="summary-note">当前暂未开放提现可以先查看收益明细</text>
<AppButton
v-else
block
:disabled="!canStartWithdrawal"
:label="canStartWithdrawal ? '申请提现' : '暂未达到最低提现金额'"
@click="openWithdrawalForm"
/>
</view>
<view class="record-tabs" role="tablist" aria-label="收益记录分类">
<button
:class="{ active: activeTab === 'ledger' }"
role="tab"
:aria-selected="activeTab === 'ledger'"
@click="activeTab = 'ledger'"
>
收益明细
</button>
<button
:class="{ active: activeTab === 'withdrawals' }"
role="tab"
:aria-selected="activeTab === 'withdrawals'"
@click="activeTab = 'withdrawals'"
>
提现记录
</button>
</view>
<view v-if="activeTab === 'ledger'" class="record-list">
<text v-if="!ledgerRows.length" class="empty-copy">还没有收益明细</text>
<view
v-for="ledgerEntry in ledgerRows"
:key="ledgerEntry.ledgerId"
class="record-card"
>
<view>
<text>{{ earningTypeLabel(ledgerEntry.entryType) }}</text>
<text>{{ recordTimeLabel(ledgerEntry.createTime) }}</text>
</view>
<text :class="{ 'amount-negative': ledgerEntry.availableDelta.startsWith('-') }">
{{ signedMoney(ledgerEntry.availableDelta) }}
</text>
<text v-if="ledgerEntry.remark">{{ ledgerEntry.remark }}</text>
</view>
</view>
<view v-else class="record-list">
<text v-if="!withdrawalRows.length" class="empty-copy">还没有提现记录</text>
<view
v-for="withdrawal in withdrawalRows"
:key="withdrawal.withdrawalId"
class="record-card"
>
<view>
<text>提现 ¥{{ withdrawal.amount }}</text>
<text>{{ recordTimeLabel(withdrawal.createTime) }}</text>
</view>
<text>{{ withdrawalStatusLabel(withdrawal.withdrawalStatus) }}</text>
<text>收款人{{ withdrawal.payoutAccountName || '未填写' }}</text>
<text v-if="withdrawal.failureReason">原因{{ withdrawal.failureReason }}</text>
<AppButton
v-if="withdrawal.withdrawalStatus === 'PENDING'"
compact
type="secondary"
label="取消提现"
@click="requestCancellation(withdrawal)"
/>
</view>
</view>
</template>
</view>
<EarningWithdrawalDialog
ref="earningWithdrawalDialog"
:available-amount="summary.availableAmount"
:minimum-withdrawal="summary.minimumWithdrawal"
:after-submitted="handleWithdrawalSubmitted"
/>
<AppDialog
:visible="Boolean(cancelTarget)"
title="取消这笔提现吗?"
:message="cancelTarget ? `将取消 ¥${cancelTarget.amount} 的提现申请,并由后端解冻对应收益。` : ''"
:confirm-text="cancelling ? '正在取消' : '确认取消'"
cancel-text="保留申请"
show-cancel
:close-on-mask="false"
@confirm="cancelWithdrawal"
@cancel="cancelTarget = null"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import EarningWithdrawalDialog from "@/components/profile/EarningWithdrawalDialog.vue";
import {
EARNING_WITHDRAWAL_STATUS_LABELS as WITHDRAWAL_STATUS_LABELS
} from "@/services/api/earning-contract.js";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { earningApi } from "@/services/api/earning-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
import {
formatSignedMoney,
parseMoneyToCents,
} from "@/utils/profile/earning-money.js";
import { isWriteOutcomeUnknown } from "@/utils/request-outcome.js";
const EARNING_TYPE_LABELS = Object.freeze({
PAYMENT_REWARD: "邀请家人奖励",
PAYMENT_REFUND_REVERSAL: "邀请奖励退款扣回",
WITHDRAWAL_FREEZE: "提现金额冻结",
WITHDRAWAL_CANCEL: "取消提现退回",
WITHDRAWAL_REJECT: "提现未通过退回",
WITHDRAWAL_PAID: "提现已到账",
WITHDRAWAL_FAILED: "提现失败退回",
});
const earningsState = ref("loading");
const pageError = ref("");
const operationNotice = ref("");
const activeTab = ref("ledger");
const summary = reactive({
availableAmount: "0.00",
frozenAmount: "0.00",
minimumWithdrawal: null,
rewardRateBps: null,
withdrawalEnabled: false,
});
const ledgerRows = ref([]);
const withdrawalRows = ref([]);
const earningWithdrawalDialog = ref(null);
const cancelTarget = ref(null);
const cancelling = ref(false);
const summaryController = createRequestController();
const ledgerController = createRequestController();
const withdrawalListController = createRequestController();
const withdrawalCancellationController = createRequestController();
let isPageActive = true;
const canStartWithdrawal = computed(() => {
const available = parseMoneyToCents(summary.availableAmount);
const minimum = parseMoneyToCents(summary.minimumWithdrawal || "0.01");
return summary.withdrawalEnabled && available !== null && minimum !== null && available >= minimum;
});
const minimumWithdrawalLabel = computed(() =>
summary.minimumWithdrawal
? ${summary.minimumWithdrawal}`
: "以提交时提示为准",
);
const signedMoney = formatSignedMoney;
const recordTimeLabel = (value) =>
formatMinuteTimestamp(value) || "未标注时间";
const earningTypeLabel = (entryType) =>
EARNING_TYPE_LABELS[entryType] || "收益变动";
const withdrawalStatusLabel = (withdrawalStatus) =>
WITHDRAWAL_STATUS_LABELS[withdrawalStatus] || "处理中";
const loadAll = async () => {
summaryController.abort();
ledgerController.abort();
withdrawalListController.abort();
earningsState.value = "loading";
pageError.value = "";
try {
const [nextSummary, ledger, withdrawals] = await Promise.all([
earningApi.getEarningSummary({ requestController: summaryController }),
earningApi.getEarningLedgerPage({ pageNum: 1, pageSize: 50 }, { requestController: ledgerController }),
earningApi.getEarningWithdrawalPage(
{ pageNum: 1, pageSize: 50 },
{ requestController: withdrawalListController },
),
]);
if (!isPageActive) return;
Object.assign(summary, nextSummary);
ledgerRows.value = ledger.rows;
withdrawalRows.value = withdrawals.rows;
earningsState.value = "ready";
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
earningsState.value = "error";
pageError.value = getRequestErrorMessage(error, "请检查网络后重新读取。");
}
};
const openWithdrawalForm = () => {
if (!canStartWithdrawal.value) return;
earningWithdrawalDialog.value?.open();
};
const handleWithdrawalSubmitted = async () => {
operationNotice.value = "提现申请已提交,可以在提现记录中查看进度。";
await loadAll();
};
const requestCancellation = (withdrawal) => {
cancelTarget.value = withdrawal;
};
const cancelWithdrawal = async () => {
if (!cancelTarget.value || cancelling.value) return;
cancelling.value = true;
try {
await earningApi.cancelEarningWithdrawal(cancelTarget.value.withdrawalId, {
requestController: withdrawalCancellationController,
});
cancelTarget.value = null;
operationNotice.value = "提现申请已取消,对应收益将按后端结果解冻。";
await loadAll();
} catch (error) {
if (!isPageActive) return;
if (isWriteOutcomeUnknown(error)) {
cancelTarget.value = null;
operationNotice.value =
"暂时无法确认是否取消成功,已重新读取提现记录,请勿立即重复操作。";
await loadAll();
} else if (!isRequestCancelled(error)) {
operationNotice.value = getRequestErrorMessage(
error,
"暂时无法取消,请稍后重试。",
);
}
} finally {
if (isPageActive) cancelling.value = false;
}
};
onShow(() => {
void loadAll();
});
onUnload(() => {
isPageActive = false;
summaryController.abort();
ledgerController.abort();
withdrawalListController.abort();
withdrawalCancellationController.abort();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.earnings-page {
min-height: 100vh;
box-sizing: border-box;
padding-bottom: 48rpx;
background: $paper;
}
.page-layer {
position: relative;
z-index: 2;
}
.earnings-content {
padding: 20rpx 24rpx 60rpx;
}
.operation-notice {
display: block;
margin-bottom: 14rpx;
padding: 16rpx 20rpx;
border: 1rpx solid rgba(159, 35, 35, 0.18);
background: rgba(255, 249, 231, 0.94);
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.5;
text-align: center;
}
.summary-panel,
.state-card,
.record-card {
@include adaptive-profile-content;
}
.summary-panel {
padding: 28rpx;
text-align: center;
}
.summary-panel > text {
display: block;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
}
.summary-panel > text:nth-child(2) {
margin: 10rpx 0 22rpx;
color: $brand-red;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(30px, 54rpx, 40px);
font-weight: 700;
}
.summary-row {
display: flex;
justify-content: space-between;
gap: 20rpx;
padding: 10rpx 0;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
}
.summary-note {
margin: 18rpx 0;
line-height: 1.6;
}
.summary-panel .app-button {
margin-top: 20rpx;
}
.record-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 22rpx;
border-bottom: 2rpx solid rgba(159, 35, 35, 0.18);
}
.record-tabs button {
min-height: 76rpx;
border: 0;
border-radius: 0;
background: transparent;
color: $ink-muted;
font-size: clamp(15px, 25rpx, 18px);
}
.record-tabs button::after {
border: 0;
}
.record-tabs button.active {
border-bottom: 4rpx solid $brand-red;
color: $brand-red;
font-weight: 700;
}
.record-list {
margin-top: 18rpx;
}
.record-card {
margin-bottom: 14rpx;
padding: 22rpx;
}
.record-card > view:first-child {
display: flex;
justify-content: space-between;
gap: 16rpx;
}
.record-card text {
display: block;
overflow-wrap: anywhere;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.55;
}
.record-card > view:first-child text:first-child,
.record-card > text:nth-child(2) {
color: $ink;
font-weight: 700;
}
.record-card > text:nth-child(2) {
margin-top: 8rpx;
color: $brand-red;
}
.record-card .amount-negative {
color: $ink-muted;
}
.record-card .app-button {
margin-top: 14rpx;
}
.empty-copy {
display: block;
padding: 70rpx 20rpx;
color: $ink-muted;
font-size: clamp(15px, 24rpx, 18px);
text-align: center;
}
.state-card {
margin-top: 20rpx;
padding: 70rpx 38rpx 40rpx;
text-align: center;
}
.state-card text {
display: block;
color: $ink-muted;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.6;
}
.state-card text:first-child {
color: $ink;
font-size: clamp(19px, 34rpx, 24px);
font-weight: 700;
}
.state-card .app-button {
margin-top: 24rpx;
}
</style>