chore: 钱包余额明细

This commit is contained in:
xiaojunnuo
2026-05-24 23:12:56 +08:00
parent 961abb0f80
commit 0a77fe0169
10 changed files with 705 additions and 174 deletions
@@ -9,6 +9,7 @@ export default function (): CreateCrudOptionsRet {
return {
crudOptions: {
request: { pageRequest },
search: { show: false },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
@@ -10,6 +10,7 @@ export default function (): CreateCrudOptionsRet {
return {
crudOptions: {
request: { pageRequest },
search: { show: false },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
@@ -1,47 +1,46 @@
<template>
<fs-page class="page-invite">
<template #header>
<div class="title">激励计划</div>
<div class="title">
激励计划
<span class="sub"> 邀请好友获取丰厚佣金奖励 </span>
</div>
<div class="more">
<a-button type="primary" @click="openAgreementDialog(false)">推广协议</a-button>
</div>
</template>
<div v-if="loaded && enabled && inviteInfo.enabled" class="invite-body">
<div class="invite-summary">
<a-row :gutter="16">
<a-col :span="6">
<a-statistic title="累计收益" :value="amountToYuan(inviteInfo.summary.totalIncomeAmount)" suffix="元" />
</a-col>
<a-col :span="6">
<a-statistic title="本月收益" :value="amountToYuan(inviteInfo.summary.monthIncomeAmount)" suffix="元" />
</a-col>
<a-col :span="6">
<a-statistic title="推广人数" :value="inviteInfo.summary.inviteeCount || 0" suffix="人" />
</a-col>
<a-col :span="6">
<a-statistic title="累计推广金额" :value="amountToYuan(inviteInfo.summary.promotionAmount)" suffix="元" />
</a-col>
</a-row>
<div class="invite-summary-grid">
<div v-for="item in summaryCards" :key="item.key" class="summary-card">
<div class="summary-title">{{ item.title }}</div>
<div class="summary-value" :class="item.className">{{ item.value }}</div>
</div>
</div>
<div class="invite-main">
<div class="invite-link-row flex-o">
<span class="label">邀请码</span>
<div class="invite-link-panel">
<div class="invite-info-row">
<span class="info-label">邀请码</span>
<fs-copyable v-model="inviteInfo.inviteCode" />
</div>
<div class="invite-link-row flex-o mt-10">
<span class="label">邀请链接</span>
<div class="invite-info-row">
<span class="info-label">邀请链接</span>
<fs-copyable v-model="inviteInfo.inviteLink" />
</div>
<div class="invite-link-row flex-o mt-10">
<span class="label">我的等级</span>
<div class="invite-info-row">
<span class="info-label">我的等级</span>
<a-button type="link" class="level-button" @click="levelDialogOpen = true">
{{ inviteInfo.currentLevel?.name || "未设置" }}
<span v-if="inviteInfo.currentLevel">{{ inviteInfo.currentLevel.commissionRate }}%</span>
<span v-if="inviteInfo.currentLevel" class="level-medal" :class="levelMedalClass(inviteInfo.currentLevel)">{{ levelMedal(inviteInfo.currentLevel) }}</span>
<span>{{ inviteInfo.currentLevel?.name || "未设置" }}</span>
<span v-if="inviteInfo.currentLevel" class="current-level-rate">{{ inviteInfo.currentLevel.commissionRate }}%</span>
</a-button>
<a-button size="small" @click="openAgreementDialog(false)">查看推广协议</a-button>
</div>
</div>
<a-tabs v-model:active-key="activeTab" class="invite-tabs">
<a-tab-pane key="invitees" tab="推广成功">
<a-tabs v-model:active-key="activeTab" class="invite-tabs" @change="handleTabChange">
<a-tab-pane key="invitees" tab="推广成功用户">
<fs-crud v-if="activeTab === 'invitees'" ref="inviteesCrudRef" class="invite-crud" v-bind="inviteesCrudBinding" />
</a-tab-pane>
<a-tab-pane key="logs" tab="收益记录">
@@ -49,6 +48,7 @@
</a-tab-pane>
</a-tabs>
</div>
<div v-else-if="loaded && enabled" class="invite-disabled">
<a-empty description="请先开通激励计划">
<a-button type="primary" @click="openAgreementDialog(true)">开通激励计划</a-button>
@@ -56,33 +56,57 @@
</div>
<a-empty v-else-if="loaded" description="激励计划未开启" />
<a-modal v-model:open="levelDialogOpen" title="推广等级" width="720px" :footer="null">
<div class="level-list">
<div v-for="level in inviteInfo.levelList" :key="level.id" class="level-item" :class="{ active: level.id === inviteInfo.currentLevel?.id }">
<div class="level-title">
<span>{{ level.name }}</span>
<a-tag v-if="level.id === inviteInfo.currentLevel?.id" color="green">当前等级</a-tag>
<a-tag v-if="level.isHidden" color="orange">专属等级</a-tag>
<a-modal v-model:open="levelDialogOpen" title="推广等级" width="820px" wrap-class-name="invite-level-modal" :footer="null">
<div class="level-modal-subtitle">推广越多等级越高返佣比例越高</div>
<div class="level-card-grid modal-level-grid">
<div v-for="level in visibleLevels" :key="level.id" class="level-card" :class="{ active: level.id === inviteInfo.currentLevel?.id }">
<div class="level-name">
<span class="level-medal" :class="levelMedalClass(level)">{{ levelMedal(level) }}</span>
{{ level.name }}
<a-tag v-if="level.isHidden" color="orange">专属</a-tag>
</div>
<div class="level-meta">佣金比例 {{ level.commissionRate }}%累计推广金额达到 {{ amountToYuan(level.minAmount) }} </div>
<div class="level-rate-label">佣金比例</div>
<div class="level-rate">{{ level.commissionRate }}%</div>
<div class="level-threshold">累计推广 {{ amountToYuan(level.minAmount) }} </div>
<a-tag v-if="level.id === inviteInfo.currentLevel?.id" class="current-tag" color="blue">当前等级</a-tag>
<div v-else-if="level.id === inviteInfo.nextLevel?.id" class="next-gap">还差 {{ amountToYuan(inviteInfo.nextLevel.gapAmount) }}</div>
</div>
<div v-if="inviteInfo.nextLevel" class="next-level">距离下一等级{{ inviteInfo.nextLevel.name }}还差 {{ amountToYuan(inviteInfo.nextLevel.gapAmount) }} 元推广金额</div>
<div v-else class="next-level">已达到当前可自动升级的最高等级</div>
</div>
<div v-if="inviteInfo.nextLevel" class="next-level">距离下一等级{{ inviteInfo.nextLevel.name }}还差 {{ amountToYuan(inviteInfo.nextLevel.gapAmount) }} 元推广金额</div>
<div v-else class="next-level">已达到当前可自动升级的最高等级</div>
</a-modal>
<a-modal
v-model:open="agreementDialogOpen"
:title="agreementDialogNeedOpen ? '开通激励计划' : '推广协议'"
width="760px"
:mask-closable="!agreementDialogNeedOpen"
:keyboard="!agreementDialogNeedOpen"
:confirm-loading="agreementSubmitting"
@ok="handleAgreementOk"
@cancel="closeAgreementDialog"
>
<div class="invite-agreement-content">{{ agreementText }}</div>
<div v-if="agreementDialogNeedOpen" class="invite-agreement-confirm">
<a-checkbox v-model:checked="agreementAgree">我已阅读并同意推广协议</a-checkbox>
</div>
<template #footer>
<a-button @click="closeAgreementDialog">{{ agreementDialogNeedOpen ? "暂不开通" : "关闭" }}</a-button>
<a-button v-if="agreementDialogNeedOpen" type="primary" :disabled="!agreementAgree" :loading="agreementSubmitting" @click="handleAgreementOk">同意并开通</a-button>
</template>
</a-modal>
</fs-page>
</template>
<script lang="ts" setup>
import { h, nextTick, onActivated, onMounted, reactive, ref } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import { computed, nextTick, onActivated, onMounted, reactive, ref } from "vue";
import { FsIcon, useFs } from "@fast-crud/fast-crud";
import { notification } from "ant-design-vue";
import * as api from "./api";
import createInviteesCrudOptions from "./crud-invitees";
import createLogsCrudOptions from "./crud-logs";
import { useSettingStore } from "/@/store/settings";
import { util } from "/@/utils";
import { useFormDialog } from "/@/use/use-dialog";
defineOptions({ name: "InviteCommission" });
@@ -91,7 +115,11 @@ const enabled = ref(false);
const activeTab = ref("invitees");
const loaded = ref(false);
const levelDialogOpen = ref(false);
const { openFormDialog } = useFormDialog();
const agreementDialogOpen = ref(false);
const agreementDialogNeedOpen = ref(false);
const agreementAgree = ref(false);
const agreementSubmitting = ref(false);
const defaultAgreementContent = "请遵守平台推广规则,不得通过虚假注册、刷单、恶意诱导等方式获取收益。平台有权对异常推广行为进行核查,并根据实际情况暂停结算或关闭激励计划资格。";
const inviteInfo = reactive<any>({
enabled: false,
@@ -111,60 +139,99 @@ function amountToYuan(amount: number) {
return util.amount.toYuan(amount || 0);
}
function renderAgreement() {
return h("div", { class: "invite-agreement-content" }, inviteInfo.agreementContent || "暂无推广协议内容");
function moneyText(amount: number) {
return `¥ ${amountToYuan(amount)}`;
}
async function openAgreementDialog(needOpenPlan: boolean) {
await openFormDialog({
title: needOpenPlan ? "开通激励计划" : "推广协议",
wrapper: {
width: 720,
maskClosable: !needOpenPlan,
keyboard: !needOpenPlan,
},
initialForm: {
agree: false,
},
body: renderAgreement,
columns: needOpenPlan
? {
agree: {
title: "确认",
type: "text",
form: {
col: { span: 24 },
component: {
name: "a-checkbox",
vModel: "checked",
},
rules: [
{
validator: async (_rule: any, value: boolean) => {
if (value === true) {
return true;
}
throw new Error("请勾选同意推广协议");
},
},
],
helper: "我已阅读并同意推广协议",
},
},
}
: {},
async onSubmit(form: any) {
if (!needOpenPlan) {
return;
}
if (form.agree !== true) {
throw new Error("请勾选同意推广协议");
}
await api.OpenInvitePlan();
notification.success({ message: "激励计划已开通" });
await refreshInvitePage(true, false);
},
});
const summaryCards = computed(() => [
{
key: "totalIncome",
title: "累计收益",
value: moneyText(inviteInfo.summary.totalIncomeAmount),
className: "income",
},
{
key: "monthIncome",
title: "本月收益",
value: moneyText(inviteInfo.summary.monthIncomeAmount),
className: "income",
},
{
key: "inviteeCount",
title: "已推广人数",
value: `${inviteInfo.summary.inviteeCount || 0}`,
className: "people",
},
]);
const visibleLevels = computed(() => {
return (inviteInfo.levelList || []).filter((level: any) => !level.disabled);
});
const agreementText = computed(() => inviteInfo.agreementContent?.trim() || defaultAgreementContent);
function levelMedal(level: any) {
const name = `${level?.name || ""}`;
if (name.includes("青铜")) {
return "铜";
}
if (name.includes("白银")) {
return "银";
}
if (name.includes("黄金")) {
return "金";
}
if (name.includes("钻石")) {
return "钻";
}
return name.slice(0, 1) || "L";
}
function levelMedalClass(level: any) {
const name = `${level?.name || ""}`;
if (name.includes("青铜")) {
return "bronze";
}
if (name.includes("白银")) {
return "silver";
}
if (name.includes("黄金")) {
return "gold";
}
if (name.includes("钻石")) {
return "diamond";
}
return "default";
}
function openAgreementDialog(needOpenPlan: boolean) {
agreementDialogNeedOpen.value = needOpenPlan;
agreementAgree.value = false;
agreementDialogOpen.value = true;
}
function closeAgreementDialog() {
agreementDialogOpen.value = false;
}
async function handleAgreementOk() {
if (!agreementDialogNeedOpen.value) {
closeAgreementDialog();
return;
}
if (!agreementAgree.value) {
notification.warning({ message: "请先勾选同意推广协议" });
return;
}
agreementSubmitting.value = true;
try {
await api.OpenInvitePlan();
notification.success({ message: "激励计划已开通" });
closeAgreementDialog();
await refreshInvitePage(false);
} finally {
agreementSubmitting.value = false;
}
}
async function loadMyInvite(autoOpenAgreement = false) {
@@ -172,7 +239,7 @@ async function loadMyInvite(autoOpenAgreement = false) {
Object.assign(inviteInfo, res || {});
if (autoOpenAgreement && !inviteInfo.enabled) {
await nextTick();
await openAgreementDialog(true);
openAgreementDialog(true);
}
}
@@ -187,7 +254,12 @@ async function refreshActiveList() {
}
}
async function refreshInvitePage(refreshAll = false, autoOpenAgreement = true) {
async function handleTabChange() {
await nextTick();
await refreshActiveList();
}
async function refreshInvitePage(autoOpenAgreement = true) {
await settingStore.initOnce();
enabled.value = settingStore.isInviteCommissionEnabled;
loaded.value = true;
@@ -199,10 +271,6 @@ async function refreshInvitePage(refreshAll = false, autoOpenAgreement = true) {
return;
}
await nextTick();
if (refreshAll) {
await Promise.all([inviteesCrudExpose.doRefresh(), logsCrudExpose.doRefresh()]);
return;
}
await refreshActiveList();
}
@@ -228,12 +296,27 @@ onActivated(async () => {
min-height: 0;
}
.invite-page-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.invite-page-subtitle {
margin-top: 4px;
color: hsl(var(--muted-foreground));
font-size: 13px;
font-weight: 400;
}
.invite-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 20px;
background: hsl(var(--background-deep));
}
.invite-disabled {
@@ -243,17 +326,126 @@ onActivated(async () => {
justify-content: center;
}
.invite-summary,
.invite-main {
.level-subtitle,
.level-modal-subtitle {
color: hsl(var(--muted-foreground));
font-size: 14px;
}
.invite-summary-grid {
display: grid;
gap: 16px;
}
.invite-summary-grid {
flex: none;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 18px;
}
.invite-summary {
margin-bottom: 16px;
.summary-card,
.invite-link-panel {
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--card));
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
}
.invite-main {
margin-bottom: 12px;
.summary-card {
min-height: 112px;
padding: 22px;
}
.summary-title {
margin-bottom: 10px;
color: hsl(var(--muted-foreground));
font-size: 15px;
}
.summary-value {
font-size: 30px;
font-weight: 700;
line-height: 36px;
}
.summary-value.income {
color: #c58a35;
}
.summary-value.people {
color: #3478f6;
}
.invite-link-panel {
flex: none;
padding: 14px 18px;
margin-bottom: 18px;
}
.invite-info-row {
display: flex;
align-items: center;
min-height: 34px;
gap: 10px;
}
.invite-info-row + .invite-info-row {
margin-top: 8px;
}
.info-label {
width: 92px;
flex: none;
color: hsl(var(--muted-foreground));
text-align: right;
white-space: nowrap;
}
.current-level-rate {
margin-left: 6px;
color: #c58a35;
font-weight: 700;
}
.level-button {
display: inline-flex;
align-items: center;
height: 28px;
padding-left: 0;
gap: 4px;
}
.level-medal {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: #f4d7a1;
color: #8a5a16;
font-size: 12px;
font-weight: 700;
}
.level-medal.bronze {
background: #f5d6b7;
color: #9a5b22;
}
.level-medal.silver {
background: #e5e7eb;
color: #4b5563;
}
.level-medal.gold {
background: #f8df9b;
color: #926c15;
}
.level-medal.diamond {
background: #dbeafe;
color: #2563eb;
}
.invite-tabs {
@@ -261,6 +453,10 @@ onActivated(async () => {
flex: 1;
flex-direction: column;
min-height: 0;
padding: 0 12px 12px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--card));
}
.ant-tabs-content-holder,
@@ -280,17 +476,115 @@ onActivated(async () => {
min-height: 0;
}
.label {
width: 80px;
flex: none;
text-align: right;
margin-right: 8px;
.invite-tabs {
.fs-search {
display: none;
}
}
}
.invite-level-modal {
.level-card-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.level-button {
padding-left: 0;
margin-right: 12px;
.level-card {
position: relative;
min-height: 132px;
padding: 16px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--card));
transition:
border-color 0.2s,
background-color 0.2s;
}
.level-card.active {
border-color: #3478f6;
background: hsl(var(--primary) / 10%);
}
.level-name {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: hsl(var(--foreground));
font-weight: 700;
}
.level-medal {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: #f4d7a1;
color: #8a5a16;
font-size: 12px;
font-weight: 700;
}
.level-medal.bronze {
background: #f5d6b7;
color: #9a5b22;
}
.level-medal.silver {
background: #e5e7eb;
color: #4b5563;
}
.level-medal.gold {
background: #f8df9b;
color: #926c15;
}
.level-medal.diamond {
background: #dbeafe;
color: #2563eb;
}
.level-rate-label {
margin-top: 12px;
color: hsl(var(--muted-foreground));
font-size: 12px;
text-align: center;
}
.level-rate {
margin-top: 2px;
color: #c58a35;
font-size: 24px;
font-weight: 700;
line-height: 30px;
text-align: center;
}
.level-threshold,
.next-gap {
margin-top: 6px;
color: hsl(var(--muted-foreground));
font-size: 12px;
text-align: center;
}
.current-tag {
display: table;
margin: 10px auto 0;
}
.next-gap {
color: #3478f6;
}
}
.modal-level-grid {
margin-top: 12px;
}
.invite-agreement-content {
@@ -300,38 +594,49 @@ onActivated(async () => {
white-space: pre-wrap;
border: 1px solid #eee;
border-radius: 6px;
background: #fafafa;
background: hsl(var(--card));
line-height: 1.7;
}
.level-list {
.level-item {
padding: 12px 14px;
border: 1px solid #eee;
border-radius: 6px;
margin-bottom: 10px;
.invite-agreement-confirm {
margin-top: 14px;
padding: 10px 12px;
border: 1px solid #e6f4ff;
border-radius: 6px;
background: #f5fbff;
}
.level-modal-subtitle {
margin-bottom: 12px;
}
.next-level {
margin-top: 16px;
color: #3478f6;
}
@media (max-width: 900px) {
.page-invite {
.invite-summary-grid,
.level-card-grid {
grid-template-columns: 1fr;
}
.invite-info-row {
align-items: stretch;
flex-direction: column;
}
.info-label {
width: auto;
text-align: left;
}
}
.level-item.active {
border-color: #52c41a;
background: #f6ffed;
}
.level-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.level-meta {
margin-top: 6px;
color: #666;
}
.next-level {
margin-top: 12px;
color: #1677ff;
.invite-level-modal {
.level-card-grid {
grid-template-columns: 1fr;
}
}
}
</style>
@@ -26,7 +26,7 @@
</div>
<div class="flex-o mt-5">
<span class="label">{{ $t("certd.order.price") }}</span>
<price-input :edit="false" :model-value="durationSelected.price"></price-input>
<price-input :edit="false" :model-value="durationSelected.price" zero-text="免费"></price-input>
</div>
<div v-if="durationSelected.price > 0 && wallet.availableAmount > 0" class="flex-o mt-5">
<span class="label">返利抵扣</span>
@@ -47,7 +47,7 @@
<div class="price flex-between mt-5">
<div class="flex-o">价格</div>
<div class="flex-o price-text">
<price-input style="color: red" :font-size="20" :model-value="selected?.price" :edit="false" />
<price-input style="color: red" :font-size="20" :model-value="selected?.price" :edit="false" zero-text="免费" />
<span class="ml-5" style="font-size: 12px"> / {{ durationDict.dataMap[selected.duration]?.label }}</span>
</div>
</div>
@@ -19,3 +19,7 @@ export async function ApplyWithdraw(amount: number) {
export async function GetWithdraws(query: any) {
return await request({ url: "/wallet/withdraw/page", method: "post", data: query });
}
export async function GetWalletLogs(query: any) {
return await request({ url: "/wallet/log/page", method: "post", data: query });
}
@@ -0,0 +1,57 @@
import { CreateCrudOptionsRet, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import * as api from "./api";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetWalletLogs(query);
};
return {
crudOptions: {
request: { pageRequest },
search: { show: false },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
columns: {
createTime: { title: "时间", type: "datetime", column: { width: 180 } },
type: {
title: "类型",
type: "dict-select",
dict: dict({
data: [
{ label: "收益入账", value: "income", color: "success" },
{ label: "余额抵扣", value: "consume", color: "default" },
{ label: "提现冻结", value: "withdraw_freeze", color: "warning" },
{ label: "提现成功", value: "withdraw_success", color: "success" },
{ label: "提现退回", value: "withdraw_reject", color: "processing" },
],
}),
column: { width: 120 },
},
amount: {
title: "变动金额",
type: "number",
column: {
width: 120,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
balanceAfter: {
title: "变动后余额",
type: "number",
column: {
width: 130,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
remark: {
title: "备注",
type: "text",
column: { minWidth: 220 },
},
},
},
};
}
@@ -10,10 +10,12 @@ export default function (): CreateCrudOptionsRet {
return {
crudOptions: {
request: { pageRequest },
search: { show: false },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
columns: {
createTime: { title: "申请时间", type: "datetime", column: { width: 180 } },
amount: {
title: "金额",
type: "number",
@@ -62,7 +64,6 @@ export default function (): CreateCrudOptionsRet {
},
},
auditRemark: { title: "审核备注", type: "text", column: { minWidth: 180 } },
createTime: { title: "申请时间", type: "datetime", column: { width: 180 } },
},
},
};
@@ -4,39 +4,40 @@
<div class="title">我的钱包</div>
</template>
<div class="wallet-body">
<a-row :gutter="16" class="wallet-summary">
<a-col :span="6">
<a-statistic title="可用余额" :value="amountToYuan(summary.availableAmount)" suffix="元" />
</a-col>
<a-col :span="6">
<a-statistic title="冻结余额" :value="amountToYuan(summary.frozenAmount)" suffix="元" />
</a-col>
<a-col :span="6">
<a-statistic title="累计收入" :value="amountToYuan(summary.totalIncomeAmount)" suffix="元" />
</a-col>
<a-col :span="6">
<a-statistic title="累计提现" :value="amountToYuan(summary.totalWithdrawAmount)" suffix="元" />
</a-col>
</a-row>
<div class="wallet-actions">
<a-space>
<a-button type="primary" @click="openWithdrawSetting">提现设置</a-button>
<a-input-number v-model:value="withdrawAmountYuan" :min="0" addon-before="提现金额" addon-after="" />
<a-button @click="applyWithdraw">申请提现</a-button>
</a-space>
<div class="wallet-summary-grid">
<div v-for="item in summaryCards" :key="item.key" class="summary-card">
<div class="summary-title">{{ item.title }}</div>
<div class="summary-value" :class="item.className">{{ item.value }}</div>
</div>
</div>
<fs-crud ref="withdrawCrudRef" class="wallet-crud" v-bind="withdrawCrudBinding" />
<div class="wallet-action-panel">
<div class="wallet-action-title">提现操作</div>
<div class="wallet-action-content">
<a-button type="primary" @click="openWithdrawSetting">提现设置</a-button>
<a-input-number v-model:value="withdrawAmountYuan" class="withdraw-amount-input" :min="0" addon-before="提现金额" addon-after="" />
<a-button @click="applyWithdraw">申请提现</a-button>
</div>
</div>
<a-tabs v-model:active-key="activeTab" class="wallet-tabs" @change="refreshActiveList">
<a-tab-pane key="withdraw" tab="提现记录">
<fs-crud v-if="activeTab === 'withdraw'" ref="withdrawCrudRef" class="wallet-crud" v-bind="withdrawCrudBinding" />
</a-tab-pane>
<a-tab-pane key="logs" tab="余额明细">
<fs-crud v-if="activeTab === 'logs'" ref="logsCrudRef" class="wallet-crud" v-bind="logsCrudBinding" />
</a-tab-pane>
</a-tabs>
</div>
</fs-page>
</template>
<script lang="ts" setup>
import { onActivated, onMounted, reactive, ref } from "vue";
import { computed, onActivated, onMounted, reactive, ref } from "vue";
import { compute, dict, useFs } from "@fast-crud/fast-crud";
import { notification } from "ant-design-vue";
import * as api from "./api";
import createLogsCrudOptions from "./crud-logs";
import createWithdrawCrudOptions from "./crud-withdraw";
import { util } from "/@/utils";
import { useFormDialog } from "/@/use/use-dialog";
@@ -47,14 +48,47 @@ defineOptions({ name: "MyWallet" });
const summary = reactive<any>({ availableAmount: 0, frozenAmount: 0, totalIncomeAmount: 0, totalWithdrawAmount: 0 });
const withdrawAmountYuan = ref(0);
const loaded = ref(false);
const activeTab = ref("withdraw");
const { openFormDialog } = useFormDialog();
const userStore = useUserStore();
const { crudBinding: withdrawCrudBinding, crudExpose: withdrawCrudExpose, crudRef: withdrawCrudRef } = useFs({ createCrudOptions: createWithdrawCrudOptions });
const { crudBinding: logsCrudBinding, crudExpose: logsCrudExpose, crudRef: logsCrudRef } = useFs({ createCrudOptions: createLogsCrudOptions });
function amountToYuan(amount: number) {
return util.amount.toYuan(amount || 0);
}
function moneyText(amount: number) {
return `¥ ${amountToYuan(amount)}`;
}
const summaryCards = computed(() => [
{
key: "availableAmount",
title: "可用余额",
value: moneyText(summary.availableAmount),
className: "available",
},
{
key: "frozenAmount",
title: "冻结余额",
value: moneyText(summary.frozenAmount),
className: "frozen",
},
{
key: "totalIncomeAmount",
title: "累计收入",
value: moneyText(summary.totalIncomeAmount),
className: "income",
},
{
key: "totalWithdrawAmount",
title: "累计提现",
value: moneyText(summary.totalWithdrawAmount),
className: "withdraw",
},
]);
async function loadWalletSummary() {
const res: any = await api.GetWalletSummary();
Object.assign(summary, res || {});
@@ -152,13 +186,21 @@ async function applyWithdraw() {
await api.ApplyWithdraw(util.amount.toCent(withdrawAmountYuan.value || 0));
withdrawAmountYuan.value = 0;
await loadWalletSummary();
await withdrawCrudExpose.doRefresh();
await Promise.all([withdrawCrudExpose.doRefresh(), logsCrudExpose.doRefresh()]);
notification.success({ message: "提现申请已提交" });
}
async function refreshActiveList() {
if (activeTab.value === "withdraw") {
await withdrawCrudExpose.doRefresh();
} else if (activeTab.value === "logs") {
await logsCrudExpose.doRefresh();
}
}
async function refreshWalletPage() {
await loadWalletSummary();
await withdrawCrudExpose.doRefresh();
await refreshActiveList();
loaded.value = true;
}
@@ -188,20 +230,138 @@ onActivated(async () => {
flex-direction: column;
min-height: 0;
padding: 20px;
background: hsl(var(--background-deep));
}
.wallet-summary,
.wallet-actions {
.wallet-summary-grid,
.wallet-action-panel {
flex: none;
}
.wallet-actions {
margin: 16px 0 10px;
.wallet-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 18px;
}
.summary-card,
.wallet-action-panel,
.wallet-tabs {
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--card));
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
}
.summary-card {
min-height: 112px;
padding: 22px;
}
.summary-title {
margin-bottom: 10px;
color: hsl(var(--muted-foreground));
font-size: 15px;
}
.summary-value {
font-size: 30px;
font-weight: 700;
line-height: 36px;
}
.summary-value.available,
.summary-value.income {
color: #c58a35;
}
.summary-value.frozen {
color: #8b96a8;
}
.summary-value.withdraw {
color: #3478f6;
}
.wallet-action-panel {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 16px;
padding: 14px 18px;
margin-bottom: 18px;
}
.wallet-action-title {
flex: none;
color: hsl(var(--foreground));
font-size: 15px;
font-weight: 600;
}
.wallet-action-content {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-start;
gap: 10px;
}
.withdraw-amount-input {
width: 240px;
}
.wallet-tabs {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 0 12px 12px;
}
.ant-tabs-content-holder,
.ant-tabs-content,
.ant-tabs-tabpane {
display: flex;
flex: 1;
min-height: 0;
}
.ant-tabs-tabpane {
flex-direction: column;
}
.wallet-crud {
flex: 1;
min-height: 0;
}
.wallet-tabs {
.fs-search {
display: none;
}
}
}
@media (max-width: 900px) {
.page-wallet {
.wallet-summary-grid {
grid-template-columns: 1fr;
}
.wallet-action-panel {
align-items: stretch;
flex-direction: column;
}
.wallet-action-content {
justify-content: flex-start;
}
.withdraw-amount-input {
width: 100%;
}
}
}
</style>
@@ -13,11 +13,13 @@ const props = withDefaults(
modelValue?: number;
edit?: boolean;
fontSize?: number;
zeroText?: string;
}>(),
{
modelValue: 0,
edit: false,
fontSize: 14,
zeroText: "¥0",
}
);
@@ -39,7 +41,7 @@ const priceValue = computed(() => {
const priceLabel = computed(() => {
if (priceValue.value === 0) {
return "免费";
return props.zeroText;
}
return `¥${priceValue.value}`;
});