feat: 商业版支持邀请返佣功能

This commit is contained in:
xiaojunnuo
2026-05-18 13:25:35 +08:00
parent 1bdcfe646f
commit f9a310b6c3
33 changed files with 1317 additions and 14 deletions
@@ -22,6 +22,8 @@ export default {
mySuite: "我的套餐",
suiteBuy: "套餐购买",
myTrade: "我的订单",
myWallet: "我的钱包",
inviteCommission: "邀请返佣",
paymentReturn: "支付返回",
source: "源码",
github: "github",
@@ -46,6 +48,8 @@ export default {
suiteSetting: "套餐设置",
orderManager: "订单管理",
userSuites: "用户套餐",
inviteCommissionSetting: "邀请返佣设置",
inviteWithdraw: "提现申请记录",
netTest: "网络测试",
enterpriseManager: "企业管理设置",
projectManager: "项目管理",
+2
View File
@@ -12,9 +12,11 @@ import plugin from "./plugin/";
import { setupVben } from "./vben";
import { util } from "/@/utils";
import { initPreferences } from "/@/vben/preferences";
import { inviteUtils } from "/@/utils/util.invite";
// import "./components/code-editor/import-works";
// @ts-ignore
async function bootstrap() {
inviteUtils.captureFromLocation();
const app = createApp(App);
// app.use(Antd);
app.use(Antd);
@@ -324,7 +324,7 @@ export const certdResources = [
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
return settingStore.isInviteCommissionEnabled;
},
icon: "ion:gift-outline",
auth: true,
@@ -359,6 +359,36 @@ export const certdResources = [
keepAlive: true,
},
},
{
title: "certd.myWallet",
name: "MyWallet",
path: "/certd/wallet",
component: "/certd/wallet/index.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:wallet-outline",
auth: true,
keepAlive: true,
},
},
{
title: "certd.inviteCommission",
name: "InviteCommission",
path: "/certd/invite",
component: "/certd/invite/index.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:gift-outline",
auth: true,
keepAlive: true,
},
},
{
title: "certd.paymentReturn",
name: "PaymentReturn",
@@ -286,6 +286,38 @@ export const sysResources = [
keepAlive: true,
},
},
{
title: "certd.sysResources.inviteCommissionSetting",
name: "SysInviteCommissionSetting",
path: "/sys/suite/invite/setting",
component: "/sys/suite/invite/setting.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:gift-outline",
permission: "sys:settings:edit",
auth: true,
keepAlive: true,
},
},
{
title: "certd.sysResources.inviteWithdraw",
name: "SysInviteWithdraw",
path: "/sys/suite/invite/withdraw",
component: "/sys/suite/invite/withdraw.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:cash-outline",
permission: "sys:settings:edit",
auth: true,
keepAlive: true,
},
},
],
},
{
@@ -97,6 +97,9 @@ export type SysPublicSetting = {
export type SuiteSetting = {
enabled?: boolean;
};
export type InviteSetting = {
enabled?: boolean;
};
export type SysPrivateSetting = {
httpProxy?: string;
httpsProxy?: string;
@@ -142,6 +145,7 @@ export type AllSettings = {
siteEnv: SiteEnv;
headerMenus: HeaderMenus;
suiteSetting: SuiteSetting;
inviteSetting: InviteSetting;
app: AppInfo;
};
@@ -30,6 +30,9 @@ export interface SettingState {
headerMenus?: HeaderMenus;
inited?: boolean;
suiteSetting?: SuiteSetting;
inviteSetting?: {
enabled?: boolean;
};
app: {
version?: string;
time?: number;
@@ -102,6 +105,7 @@ export const useSettingStore = defineStore({
menus: [],
},
suiteSetting: { enabled: false },
inviteSetting: { enabled: false },
inited: false,
app: {
version: "",
@@ -196,6 +200,9 @@ export const useSettingStore = defineStore({
// @ts-ignore
return this.suiteSetting?.enabled === true;
},
isInviteCommissionEnabled(): boolean {
return this.isComm && this.inviteSetting?.enabled === true;
},
},
actions: {
checkPlus() {
@@ -215,6 +222,7 @@ export const useSettingStore = defineStore({
merge(this.plusInfo, allSettings.plusInfo || {});
merge(this.headerMenus, allSettings.headerMenus || {});
merge(this.suiteSetting, allSettings.suiteSetting || {});
merge(this.inviteSetting, allSettings.inviteSetting || {});
//@ts-ignore
this.initSiteInfo(allSettings.siteInfo || {});
this.initAppInfo(allSettings.app || {});
@@ -4,6 +4,7 @@ export interface RegisterReq {
username: string;
password: string;
confirmPassword: string;
inviteCode?: string;
}
/**
* @description: Login interface parameters
@@ -18,6 +19,7 @@ export interface SmsLoginReq {
phoneCode: string;
smsCode: string;
randomStr: string;
inviteCode?: string;
}
export interface ForgotPasswordReq {
@@ -0,0 +1,54 @@
const INVITE_STORAGE_KEY = "certd_invite_code";
const INVITE_TTL = 3 * 24 * 60 * 60 * 1000;
export type InviteCache = {
code: string;
expiresAt: number;
};
function normalizeInviteCode(code?: string | null) {
return code?.trim().toUpperCase();
}
export const inviteUtils = {
save(code?: string | null) {
const normalized = normalizeInviteCode(code);
if (!normalized) {
return;
}
const cache: InviteCache = {
code: normalized,
expiresAt: Date.now() + INVITE_TTL,
};
localStorage.setItem(INVITE_STORAGE_KEY, JSON.stringify(cache));
},
get() {
const text = localStorage.getItem(INVITE_STORAGE_KEY);
if (!text) {
return "";
}
try {
const cache = JSON.parse(text) as InviteCache;
if (!cache.code || !cache.expiresAt || cache.expiresAt < Date.now()) {
localStorage.removeItem(INVITE_STORAGE_KEY);
return "";
}
return cache.code;
} catch (e) {
localStorage.removeItem(INVITE_STORAGE_KEY);
return "";
}
},
captureFromLocation() {
const hashQuery = window.location.hash?.split("?")[1] || "";
const search = window.location.search?.replace(/^\?/, "") || "";
const hashParams = new URLSearchParams(hashQuery);
const searchParams = new URLSearchParams(search);
const code = hashParams.get("inviteCode") || searchParams.get("inviteCode");
if (code) {
this.save(code);
}
},
};
@@ -0,0 +1,13 @@
import { request } from "/@/api/service";
export async function GetMyInvite() {
return await request({ url: "/invite/my", method: "post" });
}
export async function GetInvitees(query: any) {
return await request({ url: "/invite/invitees/page", method: "post", data: query });
}
export async function GetCommissionLogs(query: any) {
return await request({ url: "/invite/commission/page", method: "post", data: query });
}
@@ -0,0 +1,34 @@
import { CreateCrudOptionsProps, CreateCrudOptionsRet, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import * as api from "./api";
export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetInvitees(query);
};
return {
crudOptions: {
request: { pageRequest },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
columns: {
inviteeUserId: {
title: "被邀请人ID",
type: "number",
column: { width: 140 },
},
inviteCode: {
title: "邀请码",
type: "text",
column: { width: 160 },
},
createTime: {
title: "邀请时间",
type: "datetime",
column: { width: 180 },
},
},
},
};
}
@@ -0,0 +1,59 @@
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.GetCommissionLogs(query);
};
return {
crudOptions: {
request: { pageRequest },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
columns: {
type: {
title: "类型",
type: "dict-select",
dict: dict({
data: [{ label: "佣金入账", value: "commission", color: "success" }],
}),
column: { width: 130 },
},
amount: {
title: "金额",
type: "number",
column: {
width: 120,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
inviteeUserDisplay: {
title: "被邀请用户",
type: "text",
column: { width: 150 },
},
consumeAmount: {
title: "消费金额",
type: "number",
column: {
width: 120,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
remark: {
title: "备注",
type: "text",
column: { minWidth: 220 },
},
createTime: {
title: "时间",
type: "datetime",
column: { width: 180 },
},
},
},
};
}
@@ -0,0 +1,137 @@
<template>
<fs-page class="page-invite">
<template #header>
<div class="title">邀请返佣</div>
</template>
<div v-if="loaded && enabled" class="invite-body">
<div class="invite-link-row flex-o">
<span class="label">邀请码</span>
<fs-copyable v-model="inviteInfo.inviteCode" />
</div>
<div class="invite-link-row flex-o mt-10">
<span class="label">邀请链接</span>
<fs-copyable v-model="inviteInfo.inviteLink" />
</div>
<a-tabs v-model:active-key="activeTab" class="invite-tabs mt-6">
<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="佣金记录">
<fs-crud v-if="activeTab === 'logs'" ref="logsCrudRef" class="invite-crud" v-bind="logsCrudBinding" />
</a-tab-pane>
</a-tabs>
</div>
<a-empty v-else-if="loaded" description="邀请返佣未开启" />
</fs-page>
</template>
<script lang="ts" setup>
import { computed, nextTick, onActivated, onMounted, reactive, ref } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import * as api from "./api";
import createInviteesCrudOptions from "./crud-invitees";
import createLogsCrudOptions from "./crud-logs";
import { useSettingStore } from "/@/store/settings";
defineOptions({ name: "InviteCommission" });
const settingStore = useSettingStore();
const enabled = computed(() => settingStore.isInviteCommissionEnabled);
const activeTab = ref("invitees");
const inviteInfo = reactive<any>({ inviteCode: "", inviteLink: "" });
const loaded = ref(false);
const { crudBinding: inviteesCrudBinding, crudExpose: inviteesCrudExpose, crudRef: inviteesCrudRef } = useFs({ createCrudOptions: createInviteesCrudOptions });
const { crudBinding: logsCrudBinding, crudExpose: logsCrudExpose, crudRef: logsCrudRef } = useFs({ createCrudOptions: createLogsCrudOptions });
async function loadMyInvite() {
const res: any = await api.GetMyInvite();
inviteInfo.inviteCode = res.inviteCode;
inviteInfo.inviteLink = res.inviteLink;
}
async function refreshActiveList() {
if (activeTab.value === "invitees") {
await inviteesCrudExpose.doRefresh();
} else if (activeTab.value === "logs") {
await logsCrudExpose.doRefresh();
}
}
async function refreshInvitePage(refreshAll = false) {
await settingStore.initOnce();
loaded.value = true;
if (!enabled.value) {
return;
}
await loadMyInvite();
await nextTick();
if (refreshAll) {
await Promise.all([inviteesCrudExpose.doRefresh(), logsCrudExpose.doRefresh()]);
return;
}
await refreshActiveList();
}
onMounted(async () => {
await refreshInvitePage(true);
});
onActivated(async () => {
if (!loaded.value) {
return;
}
await refreshInvitePage();
});
</script>
<style lang="less">
.page-invite {
display: flex;
min-height: 0;
.fs-page-content {
display: flex;
min-height: 0;
}
.invite-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 20px;
}
.invite-tabs {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.ant-tabs-content-holder,
.ant-tabs-content,
.ant-tabs-tabpane {
display: flex;
flex: 1;
min-height: 0;
}
.ant-tabs-tabpane {
flex-direction: column;
}
.invite-crud {
flex: 1;
min-height: 0;
}
.label {
width: 80px;
flex: none;
text-align: right;
margin-right: 8px;
}
}
</style>
@@ -46,6 +46,7 @@ export type TradeCreateReq = {
duration: number;
num: number;
payType: string;
useRebateBalance?: boolean;
};
export async function TradeCreate(form: TradeCreateReq) {
@@ -28,10 +28,19 @@
<span class="label">{{ $t("certd.order.price") }}</span>
<price-input :edit="false" :model-value="durationSelected.price"></price-input>
</div>
<div v-if="durationSelected.price > 0 && wallet.availableAmount > 0" class="flex-o mt-5">
<span class="label">返利抵扣</span>
<a-switch v-model:checked="formRef.useRebateBalance" />
<span class="ml-10">可用 {{ amountToYuan(wallet.availableAmount) }} 预计抵扣 {{ amountToYuan(expectedRebateAmount) }} </span>
</div>
<div v-if="durationSelected.price > 0 && formRef.useRebateBalance" class="flex-o mt-5">
<span class="label">还需支付</span>
<span class="color-red">{{ amountToYuan(expectedThirdPartyAmount) }} </span>
</div>
<div class="flex-o mt-5">
<span class="label">{{ $t("certd.order.paymentMethod") }}</span>
<div v-if="durationSelected.price === 0">{{ $t("certd.order.free") }}</div>
<div v-if="durationSelected.price === 0 || expectedThirdPartyAmount === 0">{{ $t("certd.order.free") }}</div>
<fs-dict-select v-else v-model:value="formRef.payType" :dict="paymentsDictRef" style="width: 200px"> </fs-dict-select>
</div>
</div>
@@ -39,7 +48,7 @@
</template>
<script setup lang="tsx">
import { ref } from "vue";
import { computed, ref } from "vue";
import { GetPaymentTypes, OrderModalOpenReq, TradeCreate } from "/@/views/certd/suite/api";
import SuiteValue from "/@/views/sys/suite/product/suite-value.vue";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
@@ -49,11 +58,14 @@ import DurationValue from "/@/views/sys/suite/product/duration-value.vue";
import { useRouter } from "vue-router";
import qrcode from "qrcode";
import * as api from "/@/views/certd/suite/api";
import { GetMyInvite } from "/@/views/certd/invite/api";
import { util } from "/@/utils";
const openRef = ref(false);
const product = ref<any>(null);
const formRef = ref<any>({});
const durationSelected = ref<any>(null);
const wallet = ref<any>({ availableAmount: 0 });
async function open(opts: OrderModalOpenReq) {
openRef.value = true;
@@ -63,6 +75,13 @@ async function open(opts: OrderModalOpenReq) {
formRef.value.productId = opts.product.id;
formRef.value.duration = opts.duration;
formRef.value.num = opts.num ?? 1;
formRef.value.useRebateBalance = false;
try {
const inviteInfo: any = await GetMyInvite();
wallet.value = inviteInfo.wallet || { availableAmount: 0 };
} catch (e) {
wallet.value = { availableAmount: 0 };
}
}
const paymentsDictRef = dict({
async getData() {
@@ -77,6 +96,21 @@ const paymentsDictRef = dict({
const router = useRouter();
const expectedRebateAmount = computed(() => {
if (!formRef.value.useRebateBalance) {
return 0;
}
return Math.min(wallet.value.availableAmount || 0, durationSelected.value?.price || 0);
});
const expectedThirdPartyAmount = computed(() => {
return Math.max(0, (durationSelected.value?.price || 0) - expectedRebateAmount.value);
});
function amountToYuan(amount: number) {
return util.amount.toYuan(amount || 0);
}
async function orderCreate() {
if (durationSelected.value.price === 0) {
//如果是0,直接请求创建订单
@@ -93,7 +127,7 @@ async function orderCreate() {
return;
}
if (!formRef.value.payType) {
if (expectedThirdPartyAmount.value > 0 && !formRef.value.payType) {
notification.error({
message: "请选择支付方式",
});
@@ -104,8 +138,17 @@ async function orderCreate() {
duration: formRef.value.duration,
num: formRef.value.num ?? 1,
payType: formRef.value.payType,
useRebateBalance: formRef.value.useRebateBalance,
});
if (paymentReq.paid) {
notification.success({
message: "套餐购买成功",
});
openRef.value = false;
return;
}
async function onPaid() {
openRef.value = false;
router.push({
@@ -151,6 +151,30 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
},
},
},
rebateAmount: {
title: "返利抵扣",
type: "number",
column: {
width: 110,
component: {
name: PriceInput,
vModel: "modelValue",
edit: false,
},
},
},
thirdPartyPayAmount: {
title: "实付金额",
type: "number",
column: {
width: 110,
component: {
name: PriceInput,
vModel: "modelValue",
edit: false,
},
},
},
status: {
title: "状态",
search: { show: true },
@@ -177,6 +201,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
{ label: "支付宝", value: "alipay" },
{ label: "微信", value: "wxpay" },
{ label: "免费", value: "free" },
{ label: "返利余额", value: "rebate" },
],
}),
column: {
@@ -0,0 +1,21 @@
import { request } from "/@/api/service";
export async function GetWalletSummary() {
return await request({ url: "/wallet/summary", method: "post" });
}
export async function GetWithdrawSetting() {
return await request({ url: "/wallet/withdraw/setting/get", method: "post" });
}
export async function SaveWithdrawSetting(data: any) {
return await request({ url: "/wallet/withdraw/setting/save", method: "post", data });
}
export async function ApplyWithdraw(amount: number) {
return await request({ url: "/wallet/withdraw/apply", method: "post", data: { amount } });
}
export async function GetWithdraws(query: any) {
return await request({ url: "/wallet/withdraw/page", method: "post", data: query });
}
@@ -0,0 +1,56 @@
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.GetWithdraws(query);
};
return {
crudOptions: {
request: { pageRequest },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
columns: {
amount: {
title: "金额",
type: "number",
column: {
width: 120,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
status: {
title: "状态",
type: "dict-select",
dict: dict({
data: [
{ label: "待审核", value: "pending", color: "warning" },
{ label: "已通过", value: "approved", color: "success" },
{ label: "已拒绝", value: "rejected", color: "error" },
],
}),
column: { width: 110 },
},
channel: {
title: "提现渠道",
type: "dict-select",
dict: dict({
data: [
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
],
}),
column: { width: 110 },
},
realName: { title: "真实姓名", type: "text", column: { width: 120 } },
account: { title: "收款账号", type: "text", column: { width: 180 } },
bankName: { title: "开户银行", type: "text", column: { width: 160 } },
auditRemark: { title: "审核备注", type: "text", column: { minWidth: 180 } },
createTime: { title: "申请时间", type: "datetime", column: { width: 180 } },
},
},
};
}
@@ -0,0 +1,174 @@
<template>
<fs-page class="page-wallet">
<template #header>
<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>
<fs-crud ref="withdrawCrudRef" class="wallet-crud" v-bind="withdrawCrudBinding" />
</div>
</fs-page>
</template>
<script lang="ts" setup>
import { 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 createWithdrawCrudOptions from "./crud-withdraw";
import { util } from "/@/utils";
import { useFormDialog } from "/@/use/use-dialog";
defineOptions({ name: "MyWallet" });
const summary = reactive<any>({ availableAmount: 0, frozenAmount: 0, totalIncomeAmount: 0, totalWithdrawAmount: 0 });
const withdrawAmountYuan = ref(0);
const loaded = ref(false);
const { openFormDialog } = useFormDialog();
const { crudBinding: withdrawCrudBinding, crudExpose: withdrawCrudExpose, crudRef: withdrawCrudRef } = useFs({ createCrudOptions: createWithdrawCrudOptions });
function amountToYuan(amount: number) {
return util.amount.toYuan(amount || 0);
}
async function loadWalletSummary() {
const res: any = await api.GetWalletSummary();
Object.assign(summary, res || {});
}
async function openWithdrawSetting() {
const setting: any = await api.GetWithdrawSetting();
const initialForm = Object.assign({ channel: "alipay", realName: "", account: "", bankName: "" }, setting || {});
await openFormDialog({
title: "提现设置",
wrapper: {
width: 560,
},
initialForm,
columns: {
channel: {
title: "提现渠道",
type: "dict-radio",
dict: dict({
data: [
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
],
}),
form: {
col: { span: 24 },
rules: [{ required: true, message: "请选择提现渠道" }],
},
},
realName: {
title: "真实姓名",
type: "text",
form: {
col: { span: 24 },
rules: [{ required: true, message: "请输入真实姓名" }],
},
},
account: {
title: "收款账号",
type: "text",
form: {
col: { span: 24 },
rules: [{ required: true, message: "请输入收款账号" }],
},
},
bankName: {
title: "开户银行",
type: "text",
form: {
col: { span: 24 },
show: compute(({ form }) => form.channel === "bank"),
rules: [{ required: compute(({ form }) => form.channel === "bank"), message: "请输入开户银行" }],
},
},
},
async onSubmit(form: any) {
await api.SaveWithdrawSetting(form);
notification.success({ message: "保存成功" });
},
});
}
async function applyWithdraw() {
await api.ApplyWithdraw(util.amount.toCent(withdrawAmountYuan.value || 0));
withdrawAmountYuan.value = 0;
await loadWalletSummary();
await withdrawCrudExpose.doRefresh();
notification.success({ message: "提现申请已提交" });
}
async function refreshWalletPage() {
await loadWalletSummary();
await withdrawCrudExpose.doRefresh();
loaded.value = true;
}
onMounted(refreshWalletPage);
onActivated(async () => {
if (!loaded.value) {
return;
}
await refreshWalletPage();
});
</script>
<style lang="less">
.page-wallet {
display: flex;
min-height: 0;
.fs-page-content {
display: flex;
min-height: 0;
}
.wallet-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 20px;
}
.wallet-summary,
.wallet-actions {
flex: none;
}
.wallet-actions {
margin: 16px 0 10px;
}
.wallet-crud {
flex: 1;
min-height: 0;
}
}
</style>
@@ -1,3 +1,4 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<div class="main login-page">
<a-form v-if="!twoFactor.loginId" ref="formRef" class="user-layout-login" name="custom-validation" :model="formState" v-bind="layout" @finish="handleFinish" @finish-failed="handleFinishFailed">
@@ -108,6 +109,7 @@ import * as oauthApi from "../oauth/api";
import { notification } from "ant-design-vue";
import { request } from "/src/api/service";
import * as UserApi from "/src/store/user/api.user";
import { inviteUtils } from "/@/utils/util.invite";
const { t } = useI18n();
const route = useRoute();
@@ -136,6 +138,7 @@ const formState = reactive({
smsCode: "",
captcha: null,
smsCaptcha: null,
inviteCode: inviteUtils.get(),
});
const rules = {
@@ -78,6 +78,14 @@
</a-tab-pane>
</a-tabs>
<a-form-item v-if="registerType !== 'mobile'" has-feedback name="inviteCode" label="邀请码">
<a-input v-model:value="formState.inviteCode" placeholder="邀请码(选填)" size="large" autocomplete="off">
<template #prefix>
<fs-icon icon="ion:gift-outline"></fs-icon>
</template>
</a-input>
</a-form-item>
<a-form-item v-if="registerType !== 'mobile'">
<a-button type="primary" size="large" html-type="submit" class="login-button">注册</a-button>
</a-form-item>
@@ -97,6 +105,7 @@ import { useSettingStore } from "/@/store/settings";
import { notification } from "ant-design-vue";
import CaptchaInput from "/@/components/captcha/captcha-input.vue";
import { useRouter } from "vue-router";
import { inviteUtils } from "/@/utils/util.invite";
export default defineComponent({
name: "RegisterPage",
components: { CaptchaInput, EmailCode },
@@ -125,6 +134,7 @@ export default defineComponent({
confirmPassword: "",
captcha: null,
captchaForEmail: null,
inviteCode: inviteUtils.get(),
});
const rules = {
@@ -213,6 +223,7 @@ export default defineComponent({
email: formState.email,
captcha: registerType.value === "email" ? formState.captchaForEmail : formState.captcha,
validateCode: formState.validateCode,
inviteCode: formState.inviteCode,
}) as any
);
} finally {
@@ -0,0 +1,21 @@
import { request } from "/@/api/service";
export async function GetSettings() {
return await request({ url: "/sys/invite/settings/get", method: "post" });
}
export async function SaveSettings(data: any) {
return await request({ url: "/sys/invite/settings/save", method: "post", data });
}
export async function GetWithdraws(query: any) {
return await request({ url: "/sys/wallet/withdraw/page", method: "post", data: query });
}
export async function ApproveWithdraw(id: number, remark?: string) {
return await request({ url: "/sys/wallet/withdraw/approve", method: "post", data: { id, remark } });
}
export async function RejectWithdraw(id: number, remark: string) {
return await request({ url: "/sys/wallet/withdraw/reject", method: "post", data: { id, remark } });
}
@@ -0,0 +1,135 @@
import { compute, CreateCrudOptionsProps, CreateCrudOptionsRet, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import { Modal, notification } from "ant-design-vue";
import * as api from "./api";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
import { useFormDialog } from "/@/use/use-dialog";
export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const { openFormDialog } = useFormDialog();
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetWithdraws(query);
};
async function approve(row: any) {
Modal.confirm({
title: "确认提现已线下打款?",
async onOk() {
await api.ApproveWithdraw(row.id);
await crudExpose.doRefresh();
notification.success({ message: "已审核通过" });
},
});
}
async function reject(row: any) {
await openFormDialog({
title: "拒绝提现申请",
wrapper: {
width: 520,
},
initialForm: {
remark: "",
},
columns: {
remark: {
title: "拒绝理由",
type: "textarea",
form: {
col: {
span: 24,
},
component: {
name: "a-textarea",
vModel: "value",
rows: 4,
placeholder: "请填写拒绝理由",
},
rules: [{ required: true, message: "请填写拒绝理由" }],
},
},
},
async onSubmit(form: any) {
const remark = form.remark.trim();
if (!remark) {
notification.error({ message: "请填写拒绝理由" });
throw new Error("请填写拒绝理由");
}
await api.RejectWithdraw(row.id, remark);
await crudExpose.doRefresh();
notification.success({ message: "已拒绝并退回余额" });
},
});
}
return {
crudOptions: {
request: { pageRequest },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: {
width: 150,
fixed: "right",
buttons: {
view: { show: false },
edit: { show: false },
copy: { show: false },
remove: { show: false },
approve: {
text: "通过",
type: "link",
show: compute(({ row }) => row.status === "pending"),
click: ({ row }) => approve(row),
},
reject: {
text: "拒绝",
type: "link",
show: compute(({ row }) => row.status === "pending"),
click: ({ row }) => reject(row),
},
},
},
columns: {
userId: { title: "用户ID", type: "number", search: { show: true }, column: { width: 100 } },
amount: {
title: "金额",
type: "number",
column: {
width: 120,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
status: {
title: "状态",
type: "dict-select",
search: { show: true },
dict: dict({
data: [
{ label: "待审核", value: "pending", color: "warning" },
{ label: "已通过", value: "approved", color: "success" },
{ label: "已拒绝", value: "rejected", color: "error" },
],
}),
column: { width: 110 },
},
channel: {
title: "提现渠道",
type: "dict-select",
search: { show: true },
dict: dict({
data: [
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
],
}),
column: { width: 110 },
},
realName: { title: "真实姓名", type: "text", search: { show: true }, column: { width: 120 } },
account: { title: "收款账号", type: "text", column: { width: 180 } },
bankName: { title: "开户银行", type: "text", column: { width: 160 } },
auditRemark: { title: "审核备注", type: "text", column: { minWidth: 180 } },
createTime: { title: "申请时间", type: "datetime", column: { width: 180 } },
},
},
};
}
@@ -0,0 +1,74 @@
<template>
<fs-page class="page-sys-invite-setting">
<template #header>
<div class="title">邀请返佣设置</div>
</template>
<div class="page-body">
<a-form ref="formRef" :model="settings" :label-col="{ style: { width: '140px' } }" class="settings-form">
<a-form-item label="开启返佣" name="enabled">
<a-switch v-model:checked="settings.enabled" />
</a-form-item>
<a-form-item label="返佣比例" name="commissionRate">
<a-input-number v-model:value="settings.commissionRate" :min="0" :max="100" addon-after="%" />
</a-form-item>
<a-form-item label="最低提现金额" name="minWithdrawAmountYuan">
<a-input-number v-model:value="settings.minWithdrawAmountYuan" :min="0" addon-after="" />
</a-form-item>
<a-form-item label="提现渠道" name="withdrawChannels">
<a-checkbox-group v-model:value="settings.withdrawChannels" :options="withdrawChannelOptions" />
</a-form-item>
<a-form-item label=" ">
<a-button type="primary" @click="saveSettings">保存设置</a-button>
</a-form-item>
</a-form>
</div>
</fs-page>
</template>
<script lang="ts" setup>
import { onMounted, reactive } from "vue";
import { notification } from "ant-design-vue";
import * as api from "./api";
import { util } from "/@/utils";
import { useSettingStore } from "/@/store/settings";
defineOptions({ name: "SysInviteCommissionSetting" });
const settings = reactive<any>({ enabled: false, commissionRate: 0, minWithdrawAmountYuan: 0, withdrawChannels: ["alipay", "bank"] });
const withdrawChannelOptions = [
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
];
async function loadSettings() {
const data: any = await api.GetSettings();
settings.enabled = !!data?.enabled;
settings.commissionRate = data?.commissionRate || 0;
settings.minWithdrawAmountYuan = util.amount.toYuan(data?.minWithdrawAmount || 0);
settings.withdrawChannels = data?.withdrawChannels?.length ? data.withdrawChannels : ["alipay", "bank"];
}
async function saveSettings() {
await api.SaveSettings({
enabled: settings.enabled,
commissionRate: settings.commissionRate || 0,
minWithdrawAmount: util.amount.toCent(settings.minWithdrawAmountYuan || 0),
withdrawChannels: settings.withdrawChannels || [],
});
await useSettingStore().loadSysSettings();
notification.success({ message: "保存成功" });
}
onMounted(loadSettings);
</script>
<style lang="less">
.page-sys-invite-setting {
.page-body {
padding: 20px;
}
.settings-form {
max-width: 720px;
}
}
</style>
@@ -0,0 +1,25 @@
<template>
<fs-page class="page-sys-invite-withdraw">
<template #header>
<div class="title">提现申请记录</div>
</template>
<fs-crud ref="crudRef" v-bind="crudBinding" />
</fs-page>
</template>
<script lang="ts" setup>
import { onActivated, onMounted } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud-withdraw";
defineOptions({ name: "SysInviteWithdraw" });
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions });
onMounted(() => {
crudExpose.doRefresh();
});
onActivated(() => {
crudExpose.doRefresh();
});
</script>
@@ -174,6 +174,30 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
},
},
},
rebateAmount: {
title: "返利抵扣",
type: "number",
column: {
width: 110,
component: {
name: PriceInput,
vModel: "modelValue",
edit: false,
},
},
},
thirdPartyPayAmount: {
title: "实付金额",
type: "number",
column: {
width: 110,
component: {
name: PriceInput,
vModel: "modelValue",
edit: false,
},
},
},
status: {
title: "状态",
search: { show: true },
@@ -200,6 +224,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
{ label: "支付宝", value: "alipay" },
{ label: "微信", value: "wxpay" },
{ label: "免费", value: "free" },
{ label: "返利余额", value: "rebate" },
],
}),
column: {