feat: 新增推广等级激励功能

This commit is contained in:
xiaojunnuo
2026-05-31 01:01:30 +08:00
parent 3c2d450aa8
commit 5096df5cc0
20 changed files with 236 additions and 53 deletions
@@ -1,5 +1,11 @@
import { useSettingStore } from "/@/store/settings";
function isInviteLevelEnabled() {
const settingStore = useSettingStore();
const levelEnabled = settingStore.inviteSetting?.levelEnabled;
return settingStore.isComm && levelEnabled === true;
}
export const sysResources = [
{
title: "certd.sysResources.sysRoot",
@@ -309,8 +315,7 @@ export const sysResources = [
component: "/sys/suite/invite/level.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
return isInviteLevelEnabled();
},
icon: "ion:ribbon-outline",
permission: "sys:settings:edit",
@@ -325,8 +330,7 @@ export const sysResources = [
component: "/sys/suite/invite/user-level.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
return isInviteLevelEnabled();
},
icon: "ion:people-outline",
permission: "sys:settings:edit",
@@ -99,6 +99,8 @@ export type SuiteSetting = {
};
export type InviteSetting = {
enabled?: boolean;
levelEnabled?: boolean;
fixedCommissionRate?: number;
};
export type SysPrivateSetting = {
httpProxy?: string;
@@ -1,7 +1,7 @@
import { defineStore } from "pinia";
import { notification } from "ant-design-vue";
import * as basicApi from "./api.basic";
import { AppInfo, HeaderMenus, PlusInfo, SiteEnv, SiteInfo, SuiteSetting, SysInstallInfo, SysPublicSetting } from "./api.basic";
import { AppInfo, HeaderMenus, InviteSetting, PlusInfo, SiteEnv, SiteInfo, SuiteSetting, SysInstallInfo, SysPublicSetting } from "./api.basic";
import { useUserStore } from "../user";
import { mitter } from "/@/utils/util.mitt";
import { env } from "/@/utils/util.env";
@@ -30,9 +30,7 @@ export interface SettingState {
headerMenus?: HeaderMenus;
inited?: boolean;
suiteSetting?: SuiteSetting;
inviteSetting?: {
enabled?: boolean;
};
inviteSetting?: InviteSetting;
app: {
version?: string;
time?: number;
@@ -105,7 +103,7 @@ export const useSettingStore = defineStore({
menus: [],
},
suiteSetting: { enabled: false },
inviteSetting: { enabled: false },
inviteSetting: { enabled: false, levelEnabled: false, fixedCommissionRate: 10 },
inited: false,
app: {
version: "",
@@ -45,7 +45,7 @@
</div>
</div>
<div class="invite-info-row invite-highlight-row level-highlight-row" @click="levelDialogOpen = true">
<div v-if="inviteInfo.levelEnabled" class="invite-info-row invite-highlight-row level-highlight-row" @click="levelDialogOpen = true">
<div class="info-icon level-info-icon">
<fs-icon v-if="inviteInfo.currentLevel" :icon="levelIcon(inviteInfo.currentLevel)" />
<fs-icon v-else icon="ion:ribbon-outline" />
@@ -61,6 +61,19 @@
</div>
<fs-icon class="level-open-icon" icon="ion:chevron-forward-outline" />
</div>
<div v-else class="invite-info-row invite-highlight-row">
<div class="info-icon level-info-icon">
<fs-icon icon="ion:cash-outline" />
</div>
<span class="info-label">返佣比例</span>
<div class="info-content level-info-content">
<span class="current-level-rate fixed-rate-tag">
<span class="current-level-rate-label">比例</span>
<span class="current-level-rate-value">{{ inviteInfo.fixedCommissionRate || 0 }}%</span>
</span>
<span class="level-rate-desc">好友付费后按此比例计算佣金</span>
</div>
</div>
</div>
<a-tabs v-model:active-key="activeTab" class="invite-tabs" @change="handleTabChange">
@@ -80,7 +93,7 @@
</div>
<a-empty v-else-if="loaded" description="激励计划未开启" />
<a-modal v-model:open="levelDialogOpen" title="推广等级" width="820px" wrap-class-name="invite-level-modal" :footer="null">
<a-modal v-if="inviteInfo.levelEnabled" v-model:open="levelDialogOpen" title="推广等级" width="820px" wrap-class-name="invite-level-modal" :footer="null">
<div class="level-modal-subtitle">推广越多等级越高返佣比例越高</div>
<div class="level-progress-box">
<div>
@@ -88,13 +101,13 @@
<div class="level-progress-value">¥ {{ amountToYuan(inviteInfo.summary.promotionAmount) }}</div>
</div>
<div class="level-progress-desc">
<template v-if="inviteInfo.nextLevel">距离下一等级{{ inviteInfo.nextLevel.name }}还差 {{ amountToYuan(inviteInfo.nextLevel.gapAmount) }} </template>
<template v-else-if="inviteInfo.currentLevel?.levelType === 'exclusive'">当前为专属等级不参与自动升级</template>
<template v-if="inviteInfo.currentLevel?.levelType === 'exclusive'">当前为专属等级不参与自动升级</template>
<template v-else-if="inviteInfo.nextLevel">距离下一等级{{ inviteInfo.nextLevel.name }}还差 {{ amountToYuan(inviteInfo.nextLevel.gapAmount) }} </template>
<template v-else>已达到当前可自动升级的最高等级</template>
</div>
</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 v-for="level in visibleLevels" :key="level.id" class="level-card" :class="{ active: level.id === inviteInfo.currentLevel?.id, exclusive: level.levelType === 'exclusive' }">
<div class="level-name">
<span class="level-medal">
<fs-icon :icon="levelIcon(level)" />
@@ -110,8 +123,6 @@
<div v-else-if="level.id === inviteInfo.nextLevel?.id" class="next-gap">还差 {{ amountToYuan(inviteInfo.nextLevel.gapAmount) }}</div>
</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
@@ -169,6 +180,8 @@ const inviteInfo = reactive<any>({
agreementContent: "",
summary: { totalIncomeAmount: 0, monthIncomeAmount: 0, promotionAmount: 0, inviteeCount: 0 },
wallet: { availableAmount: 0 },
levelEnabled: false,
fixedCommissionRate: 10,
currentLevel: null,
nextLevel: null,
levelList: [],
@@ -213,7 +226,12 @@ const summaryCards = computed(() => [
]);
const visibleLevels = computed(() => {
return (inviteInfo.levelList || []).filter((level: any) => !level.disabled);
return (inviteInfo.levelList || []).filter((level: any) => {
if (level.disabled) {
return false;
}
return level.levelType !== "exclusive" || level.id === inviteInfo.currentLevel?.id;
});
});
const agreementText = computed(() => inviteInfo.agreementContent?.trim() || defaultAgreementContent);
@@ -546,6 +564,10 @@ onActivated(async () => {
line-height: 24px;
}
.fixed-rate-tag {
margin-left: 0;
}
.level-info-content {
display: flex;
align-items: center;
@@ -666,6 +688,18 @@ onActivated(async () => {
background: linear-gradient(145deg, rgba(236, 244, 255, 0.92), rgba(248, 250, 252, 0.88)), hsl(var(--primary) / 10%);
}
.level-card.exclusive {
border-color: rgba(147, 51, 234, 0.36);
background: linear-gradient(145deg, rgba(255, 255, 255, 0.94), rgba(250, 245, 255, 0.86)), linear-gradient(135deg, rgba(147, 51, 234, 0.24), rgba(245, 158, 11, 0.2));
box-shadow: 0 10px 28px rgba(88, 28, 135, 0.14);
}
.level-card.exclusive.active {
border-color: rgba(147, 51, 234, 0.64);
background: linear-gradient(145deg, rgba(250, 245, 255, 0.96), rgba(255, 251, 235, 0.9)), linear-gradient(135deg, rgba(147, 51, 234, 0.28), rgba(245, 158, 11, 0.24));
box-shadow: 0 14px 34px rgba(88, 28, 135, 0.2);
}
.level-name {
display: flex;
align-items: center;
@@ -47,3 +47,11 @@ export async function ApproveWithdraw(id: number, remark?: string) {
export async function RejectWithdraw(id: number, remark: string) {
return await request({ url: "/sys/wallet/withdraw/reject", method: "post", data: { id, remark } });
}
export async function GetSimpleUserByIds(ids: number[]) {
return await request({
url: "/sys/authority/user/getSimpleUserByIds",
method: "post",
data: { ids },
});
}
@@ -3,6 +3,7 @@ import { 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";
import createCrudOptionsUser from "/@/views/sys/authority/user/crud";
export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const { openFormDialog } = useFormDialog();
@@ -11,6 +12,13 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
value: "id",
label: "name",
});
const userDict = dict({
async getNodesByValues(ids: number[]) {
return await api.GetSimpleUserByIds(ids);
},
value: "id",
label: "nickName",
});
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetUserLevels(query);
@@ -65,15 +73,20 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
},
},
columns: {
userId: { title: "用户ID", type: "number", search: { show: true }, column: { width: 100 } },
username: {
title: "用户名",
type: "text",
userId: {
title: "用户",
type: "table-select",
search: { show: true },
column: {
width: 180,
cellRender({ row }) {
return row.simpleUser?.displayName || row.userDisplay || row.username || row.userId;
dict: userDict,
form: {
show: false,
component: {
crossPage: true,
multiple: false,
select: {
placeholder: "点击选择用户",
},
createCrudOptions: createCrudOptionsUser,
},
},
},
@@ -4,6 +4,7 @@ import * as api from "./api";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
import { useFormDialog } from "/@/use/use-dialog";
import { useUserStore } from "/@/store/user";
import createCrudOptionsUser from "/@/views/sys/authority/user/crud";
function buildPrivateFileUrl(key: string) {
const userStore = useUserStore();
@@ -12,6 +13,13 @@ function buildPrivateFileUrl(key: string) {
export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const { openFormDialog } = useFormDialog();
const userDict = dict({
async getNodesByValues(ids: number[]) {
return await api.GetSimpleUserByIds(ids);
},
value: "id",
label: "nickName",
});
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetWithdraws(query);
@@ -25,7 +33,7 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
<span class={"text-red-500"}>{row.amount / 100} </span>
</a-descriptions-item>
<a-descriptions-item label="渠道类型">{row.channel === "bank" ? "银行卡" : "支付宝"}</a-descriptions-item>
<a-descriptions-item label="用户">{row.userDisplay || row.userId}</a-descriptions-item>
<a-descriptions-item label="用户ID">{row.userId}</a-descriptions-item>
<a-descriptions-item label="账号">{row.account || "-"}</a-descriptions-item>
{isBank ? <a-descriptions-item label="开户行名称">{row.bankName || "-"}</a-descriptions-item> : null}
{!isBank ? <a-descriptions-item label="收款二维码">{row.qrCode ? <a-image src={buildPrivateFileUrl(row.qrCode)} width={160} /> : <span>-</span>}</a-descriptions-item> : null}
@@ -122,7 +130,24 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
},
},
columns: {
userId: { title: "用户ID", type: "number", search: { show: true }, column: { width: 100 } },
createTime: { title: "申请时间", type: "datetime", column: { width: 180 } },
userId: {
title: "用户",
type: "table-select",
search: { show: true },
dict: userDict,
form: {
show: false,
component: {
crossPage: true,
multiple: false,
select: {
placeholder: "点击选择用户",
},
createCrudOptions: createCrudOptionsUser,
},
},
},
amount: {
title: "金额",
type: "number",
@@ -131,7 +156,6 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
userDisplay: { title: "用户名", type: "text", search: { show: true }, column: { width: 140 } },
status: {
title: "状态",
type: "dict-select",
@@ -173,7 +197,6 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
},
},
auditRemark: { title: "审核备注", type: "text", column: { minWidth: 180 } },
createTime: { title: "申请时间", type: "datetime", column: { width: 180 } },
},
},
};
@@ -6,7 +6,7 @@
<fs-crud ref="crudRef" v-bind="crudBinding">
<a-empty v-if="levelList.length === 0" class="level-empty" />
<div v-else class="level-card-grid">
<div v-for="(item, index) of levelList" :key="item.id" class="level-card" :class="{ disabled: item.disabled }">
<div v-for="(item, index) of levelList" :key="item.id" class="level-card" :class="{ disabled: item.disabled, exclusive: item.levelType === 'exclusive' }">
<div class="level-card-actions">
<a-tooltip title="编辑">
<a-button type="text" size="small" @click="openEdit({ index, row: item })">
@@ -153,6 +153,17 @@ onActivated(() => {
transform: translateY(-3px);
}
.level-card.exclusive {
border-color: rgba(147, 51, 234, 0.34);
background: linear-gradient(145deg, rgba(255, 255, 255, 0.92), rgba(250, 245, 255, 0.86)), linear-gradient(135deg, rgba(147, 51, 234, 0.24), rgba(245, 158, 11, 0.2));
box-shadow: 0 12px 32px rgba(88, 28, 135, 0.14);
}
.level-card.exclusive:hover {
border-color: rgba(147, 51, 234, 0.52);
box-shadow: 0 18px 42px rgba(88, 28, 135, 0.2);
}
.level-card.disabled {
opacity: 0.66;
}
@@ -8,6 +8,15 @@
<a-form-item label="开启激励计划" name="enabled">
<a-switch v-model:checked="settings.enabled" />
</a-form-item>
<a-form-item label="启用推广等级" name="levelEnabled">
<a-space>
<a-switch v-model:checked="settings.levelEnabled" />
<a-button v-if="levelEnabled" type="link" @click="gotoInviteLevel">推广等级设置</a-button>
</a-space>
</a-form-item>
<a-form-item v-if="!settings.levelEnabled" label="佣金比例" name="fixedCommissionRate">
<a-input-number v-model:value="settings.fixedCommissionRate" :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>
@@ -38,13 +47,19 @@
<script lang="ts" setup>
import { computed, onMounted, reactive } from "vue";
import { notification } from "ant-design-vue";
import { useRouter } from "vue-router";
import * as api from "./api";
import { util } from "/@/utils";
import { useSettingStore } from "/@/store/settings";
import { useUserStore } from "/@/store/user";
import { useAccessStore } from "/@/vben/stores";
import { frameworkRoutes } from "/@/router/resolve";
import { generateMenus } from "/@/vben/utils";
import utilPermission from "/@/plugin/permission/util.permission";
defineOptions({ name: "SysInviteCommissionSetting" });
const router = useRouter();
const defaultAgreement = "<p>请遵守平台推广规则,不得通过虚假注册、刷单、恶意诱导等方式获取收益。平台有权对异常推广行为进行核查,并根据实际情况暂停结算或关闭激励计划资格。</p>";
const defaultWithdrawBanks = [
"中国工商银行",
@@ -63,13 +78,22 @@ const defaultWithdrawBanks = [
"兴业银行",
"浦发银行",
];
const settings = reactive<any>({ enabled: false, agreementContent: "", minWithdrawAmountYuan: 0, withdrawChannels: ["alipay", "bank"], withdrawBanks: defaultWithdrawBanks });
const settings = reactive<any>({
enabled: false,
levelEnabled: false,
fixedCommissionRate: 10,
agreementContent: "",
minWithdrawAmountYuan: 0,
withdrawChannels: ["alipay", "bank"],
withdrawBanks: defaultWithdrawBanks,
});
const withdrawChannelOptions = [
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
];
const bankOptions = computed(() => defaultWithdrawBanks.map(item => ({ label: item, value: item })));
const bankChannelEnabled = computed(() => settings.withdrawChannels?.includes("bank"));
const levelEnabled = computed(() => settings.levelEnabled === true);
const userStore = useUserStore();
const editorUploader = {
type: "form",
@@ -89,6 +113,8 @@ const editorUploader = {
async function loadSettings() {
const data: any = await api.GetSettings();
settings.enabled = !!data?.enabled;
settings.levelEnabled = data?.levelEnabled === true;
settings.fixedCommissionRate = Number(data?.fixedCommissionRate) || 10;
settings.agreementContent = data?.agreementContent || defaultAgreement;
settings.minWithdrawAmountYuan = util.amount.toYuan(data?.minWithdrawAmount || 0);
settings.withdrawChannels = data?.withdrawChannels?.length ? data.withdrawChannels : ["alipay", "bank"];
@@ -101,17 +127,57 @@ async function saveSettings() {
notification.warning({ message: "请填写推广协议内容" });
return;
}
if (!levelEnabled.value && (!settings.fixedCommissionRate || settings.fixedCommissionRate <= 0)) {
notification.warning({ message: "关闭推广等级时,请设置佣金比例" });
return;
}
await api.SaveSettings({
enabled: settings.enabled,
levelEnabled: levelEnabled.value,
fixedCommissionRate: settings.fixedCommissionRate || 0,
agreementContent: settings.agreementContent || "",
minWithdrawAmount: util.amount.toCent(settings.minWithdrawAmountYuan || 0),
withdrawChannels: settings.withdrawChannels || [],
withdrawBanks,
});
await useSettingStore().loadSysSettings();
await refreshMenus();
notification.success({ message: "保存成功" });
}
function gotoInviteLevel() {
router.push({ path: "/sys/suite/invite/level" });
}
async function refreshMenus() {
const accessStore = useAccessStore();
const settingStore = useSettingStore();
let allMenus = await generateMenus(frameworkRoutes[0].children, router);
allMenus = allMenus.concat(settingStore.getHeaderMenus);
allMenus = buildAccessedMenus(allMenus);
accessStore.setAccessMenus(allMenus);
}
function buildAccessedMenus(menus: any) {
if (menus == null) {
return [];
}
const list: any = [];
for (const sub of menus) {
if (sub.meta?.permission != null && !utilPermission.hasPermissions(sub.meta.permission)) {
continue;
}
const item: any = {
...sub,
};
list.push(item);
if (sub.children && sub.children.length > 0) {
item.children = buildAccessedMenus(sub.children);
}
}
return list;
}
function isBlankAgreement(content: string) {
const text = `${content || ""}`
.replace(/<[^>]*>/g, "")