feat: 商业版支持邀请推广功能

This commit is contained in:
xiaojunnuo
2026-05-26 01:08:17 +08:00
parent ba1fe54ef8
commit f1d2a1033a
18 changed files with 531 additions and 264 deletions
@@ -24,6 +24,10 @@ export async function UpdateLevel(data: any) {
return await request({ url: "/sys/invite/level/update", method: "post", data });
}
export async function DeleteLevel(id: number) {
return await request({ url: "/sys/invite/level/delete", method: "post", params: { id } });
}
export async function GetUserLevels(query: any) {
return await request({ url: "/sys/invite/user/page", method: "post", data: query });
}
@@ -4,6 +4,7 @@ import PriceInput from "/@/views/sys/suite/product/price-input.vue";
export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
query.sort = { prop: "sort", asc: true };
return await api.GetLevels(query);
};
const addRequest = async ({ form }: AddReq) => {
@@ -14,8 +15,7 @@ export default function (): CreateCrudOptionsRet {
return await api.UpdateLevel(form);
};
const delRequest = async ({ row }: DelReq) => {
row.disabled = true;
return await api.UpdateLevel(row);
return await api.DeleteLevel(row.id);
};
return {
@@ -49,6 +49,25 @@ export default function (): CreateCrudOptionsRet {
},
column: { width: 140 },
},
icon: {
title: "等级图标",
type: "icon",
form: {
value: "ion:ribbon-outline",
rules: [{ required: true, message: "请选择等级图标" }],
},
column: {
width: 90,
align: "center",
component: {
name: "fs-icon",
vModel: "icon",
style: {
fontSize: "22px",
},
},
},
},
minAmount: {
title: "升级金额",
type: "number",
@@ -70,23 +89,29 @@ export default function (): CreateCrudOptionsRet {
},
column: { width: 110, align: "center", cellRender: ({ value }) => `${value || 0}%` },
},
isHidden: {
title: "隐藏等级",
type: "dict-switch",
levelType: {
title: "等级类型",
type: "dict-radio",
dict: dict({
data: [
{ label: "普通等级", value: false, color: "success" },
{ label: "隐藏等级", value: true, color: "warning" },
{ label: "普通等级", value: "normal", color: "success" },
{ label: "专属等级", value: "exclusive", color: "warning" },
],
}),
form: { value: false },
form: {
value: "normal",
helper: "专属等级可由管理员手动指定,不参与普通用户自动升级。",
},
column: { width: 120, align: "center" },
},
sort: {
title: "排序",
type: "number",
form: { value: 10 },
column: { width: 90, align: "center" },
form: {
value: 10,
helper: "排序号越小越靠前。",
},
column: { width: 90, align: "center", sorter: true },
},
disabled: {
title: "状态",
@@ -46,7 +46,7 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
}),
form: {
col: { span: 24 },
helper: "隐藏等级会自动锁定,不参与自动升级。",
helper: "专属等级会自动锁定,不参与自动升级。",
},
},
},
@@ -111,13 +111,13 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
}),
column: { width: 110, align: "center" },
},
isHidden: {
title: "隐藏等级",
type: "dict-switch",
levelType: {
title: "等级类型",
type: "dict-select",
dict: dict({
data: [
{ label: "", value: false, color: "default" },
{ label: "", value: true, color: "warning" },
{ label: "普通等级", value: "normal", color: "success" },
{ label: "专属等级", value: "exclusive", color: "warning" },
],
}),
column: { width: 100, align: "center", show: compute(({ row }) => row.levelId) },
@@ -18,16 +18,17 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
};
function renderWithdrawDetail(row: any) {
const isBank = row.channel === "bank";
return (
<a-descriptions class={"w-full"} bordered column={2} size={"small"}>
<a-descriptions class={"w-full"} bordered column={1} size={"small"}>
<a-descriptions-item label="提现金额">
<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="账号">{row.account || "-"}</a-descriptions-item>
<a-descriptions-item label="开户行名称">{row.bankName || "-"}</a-descriptions-item>
<a-descriptions-item label="收款二维码" span={2}>
{row.qrCode ? <a-image src={buildPrivateFileUrl(row.qrCode)} width={160} /> : <span>-</span>}
</a-descriptions-item>
<a-descriptions-item label="提现金额">{row.amount / 100} </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}
</a-descriptions>
);
}
@@ -37,6 +38,11 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
title: "提现审核",
wrapper: {
width: 760,
buttons: {
ok: {
text: "确认已转账完成",
},
},
},
body: () => renderWithdrawDetail(row),
onSubmit: async () => {
@@ -153,17 +159,16 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
},
realName: { title: "真实姓名", type: "text", search: { show: true }, column: { width: 120 } },
account: { title: "收款账号", type: "text", column: { width: 180 } },
bankName: { title: "开户银行", type: "text", column: { width: 160 } },
qrCode: {
title: "收款二维码",
bankName: {
title: "开户银行",
type: "text",
column: {
width: 120,
cellRender({ value }) {
if (!value) {
width: 160,
cellRender({ row, value }) {
if (row.channel !== "bank") {
return "-";
}
return <a-image src={buildPrivateFileUrl(value)} width={48} />;
return value || "-";
},
},
},
@@ -3,18 +3,94 @@
<template #header>
<div class="title">推广等级</div>
</template>
<fs-crud ref="crudRef" v-bind="crudBinding" />
<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 class="level-card-actions">
<a-tooltip title="编辑">
<a-button type="text" size="small" @click="openEdit({ index, row: item })">
<template #icon><fs-icon icon="ion:create-outline" /></template>
</a-button>
</a-tooltip>
<a-tooltip :title="item.disabled ? '启用' : '禁用'">
<a-button type="text" size="small" @click="toggleDisabled(item)">
<template #icon><fs-icon :icon="item.disabled ? 'ion:play-outline' : 'ion:pause-outline'" /></template>
</a-button>
</a-tooltip>
<a-tooltip title="删除">
<a-button type="text" danger size="small" @click="confirmRemove({ index, row: item })">
<template #icon><fs-icon icon="ion:trash-outline" /></template>
</a-button>
</a-tooltip>
</div>
<div class="level-name">
<span class="level-medal">
<fs-icon :icon="levelIcon(item)" />
</span>
{{ item.name }}
<a-tag v-if="item.levelType === 'exclusive'" color="orange">专属</a-tag>
</div>
<div class="level-rate-label">佣金比例</div>
<div class="level-rate">{{ item.commissionRate || 0 }}%</div>
<div class="level-threshold">累计推广 {{ amountToYuan(item.minAmount) }} </div>
<div class="level-meta">
<a-tag :color="item.disabled ? 'default' : 'success'">{{ item.disabled ? "已禁用" : "已启用" }}</a-tag>
<span>排序 {{ item.sort || 0 }}</span>
</div>
</div>
</div>
</fs-crud>
</fs-page>
</template>
<script lang="ts" setup>
import { onActivated, onMounted } from "vue";
import { computed, onActivated, onMounted } from "vue";
import { Modal, notification } from "ant-design-vue";
import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud-level";
import * as api from "./api";
import { util } from "/@/utils";
defineOptions({ name: "SysInviteLevel" });
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions });
const levelList = computed(() => crudBinding.value?.data || []);
function amountToYuan(amount: number) {
return util.amount.toYuan(amount || 0);
}
function levelIcon(level: any) {
return level?.icon || "ion:ribbon-outline";
}
function openEdit(opts: any) {
crudExpose.openEdit(opts);
}
async function toggleDisabled(row: any) {
await api.UpdateLevel({
...row,
disabled: !row.disabled,
});
notification.success({ message: row.disabled ? "已启用" : "已禁用" });
await crudExpose.doRefresh();
}
function confirmRemove(opts: any) {
Modal.confirm({
title: "确认删除推广等级?",
content: "删除后不可恢复。如果该等级已被用户使用,可能会出现异常,请确认已完成数据处理。",
okText: "确认删除",
okType: "danger",
onOk: async () => {
await api.DeleteLevel(opts.row.id);
notification.success({ message: "已删除" });
await crudExpose.doRefresh();
},
});
}
onMounted(() => {
crudExpose.doRefresh();
@@ -23,3 +99,130 @@ onActivated(() => {
crudExpose.doRefresh();
});
</script>
<style lang="less">
.page-sys-invite-level {
.fs-crud-table {
display: none;
}
.level-card-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
padding: 4px 0;
}
.level-empty {
padding: 64px 0;
}
.level-card {
position: relative;
min-height: 156px;
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.disabled {
opacity: 0.66;
}
.level-card-actions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 2px;
.ant-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
}
.fs-icon {
font-size: 16px;
}
}
.level-name {
display: flex;
align-items: center;
justify-content: center;
min-height: 26px;
padding: 0 72px;
gap: 6px;
color: hsl(var(--foreground));
font-weight: 700;
text-align: center;
}
.level-medal {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
color: #8a5a16;
font-size: 20px;
}
.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 {
margin-top: 6px;
color: hsl(var(--muted-foreground));
font-size: 12px;
text-align: center;
}
.level-meta {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 10px;
color: hsl(var(--muted-foreground));
font-size: 12px;
}
}
@media (max-width: 1200px) {
.page-sys-invite-level {
.level-card-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
}
@media (max-width: 900px) {
.page-sys-invite-level {
.level-card-grid {
grid-template-columns: 1fr;
}
}
}
</style>
@@ -8,16 +8,26 @@
<a-form-item label="开启激励计划" name="enabled">
<a-switch v-model:checked="settings.enabled" />
</a-form-item>
<a-form-item label="推广协议" name="agreementContent">
<a-textarea v-model:value="settings.agreementContent" :rows="10" placeholder="请输入用户开通激励计划前需要确认的推广协议内容" />
</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-form-item v-if="bankChannelEnabled" label="开户银行" name="withdrawBanks">
<a-select v-model:value="settings.withdrawBanks" mode="tags" :options="bankOptions" placeholder="请选择或输入支持的开户银行" :token-separators="['', ',', '']" />
</a-form-item>
<a-form-item label="推广协议" name="agreementContent">
<fs-editor-wang5
v-model="settings.agreementContent"
:toolbar-config="{}"
:editor-config="{ placeholder: '请输入用户开通激励计划前需要确认的推广协议内容' }"
:uploader="editorUploader"
:container="{ class: 'agreement-editor' }"
style="height: 400px"
/>
</a-form-item>
<a-form-item label=" " :colon="false">
<a-button type="primary" @click="saveSettings">保存设置</a-button>
</a-form-item>
</a-form>
@@ -26,20 +36,55 @@
</template>
<script lang="ts" setup>
import { onMounted, reactive } from "vue";
import { computed, onMounted, reactive } from "vue";
import { notification } from "ant-design-vue";
import * as api from "./api";
import { util } from "/@/utils";
import { useSettingStore } from "/@/store/settings";
import { useUserStore } from "/@/store/user";
defineOptions({ name: "SysInviteCommissionSetting" });
const defaultAgreement = "请遵守平台推广规则,不得通过虚假注册、刷单、恶意诱导等方式获取收益。平台有权对异常推广行为进行核查,并根据实际情况暂停结算或关闭激励计划资格。";
const settings = reactive<any>({ enabled: false, agreementContent: "", minWithdrawAmountYuan: 0, withdrawChannels: ["alipay", "bank"] });
const defaultAgreement = "<p>请遵守平台推广规则,不得通过虚假注册、刷单、恶意诱导等方式获取收益。平台有权对异常推广行为进行核查,并根据实际情况暂停结算或关闭激励计划资格。</p>";
const defaultWithdrawBanks = [
"中国工商银行",
"中国农业银行",
"中国银行",
"中国建设银行",
"交通银行",
"招商银行",
"中国邮政储蓄银行",
"中信银行",
"中国光大银行",
"华夏银行",
"中国民生银行",
"广发银行",
"平安银行",
"兴业银行",
"浦发银行",
];
const settings = reactive<any>({ enabled: false, 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 userStore = useUserStore();
const editorUploader = {
type: "form",
action: "/basic/file/upload?autoSave=true&token=" + userStore.getToken,
name: "file",
headers: {
Authorization: "Bearer " + userStore.getToken,
},
successHandle(res: any) {
return res;
},
buildUrl(res: any) {
return res.url || `/api/basic/file/download?key=${encodeURIComponent(res.key)}`;
},
};
async function loadSettings() {
const data: any = await api.GetSettings();
@@ -47,19 +92,34 @@ async function loadSettings() {
settings.agreementContent = data?.agreementContent || defaultAgreement;
settings.minWithdrawAmountYuan = util.amount.toYuan(data?.minWithdrawAmount || 0);
settings.withdrawChannels = data?.withdrawChannels?.length ? data.withdrawChannels : ["alipay", "bank"];
settings.withdrawBanks = data?.withdrawBanks?.length ? data.withdrawBanks : defaultWithdrawBanks;
}
async function saveSettings() {
const withdrawBanks = bankChannelEnabled.value ? (settings.withdrawBanks || []).map((item: string) => item?.trim()).filter(Boolean) : [];
if (isBlankAgreement(settings.agreementContent)) {
notification.warning({ message: "请填写推广协议内容" });
return;
}
await api.SaveSettings({
enabled: settings.enabled,
agreementContent: settings.agreementContent || "",
minWithdrawAmount: util.amount.toCent(settings.minWithdrawAmountYuan || 0),
withdrawChannels: settings.withdrawChannels || [],
withdrawBanks,
});
await useSettingStore().loadSysSettings();
notification.success({ message: "保存成功" });
}
function isBlankAgreement(content: string) {
const text = `${content || ""}`
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, "")
.trim();
return !text;
}
onMounted(loadSettings);
</script>
@@ -71,5 +131,8 @@ onMounted(loadSettings);
.settings-form {
max-width: 860px;
}
.agreement-editor {
min-height: 420px;
}
}
</style>
@@ -1,6 +1,6 @@
<template>
<div class="flex-o price-input">
<a-input-number v-if="edit" prefix="¥" :value="priceValue" :precision="2" class="ml-5" @update:value="onPriceChange"> </a-input-number>
<a-input-number v-if="edit" prefix="¥" :value="priceValue" :precision="2" class="price-input-number" @update:value="onPriceChange"> </a-input-number>
<span v-else class="price-text" :style="style">{{ priceLabel }}</span>
</div>
</template>
@@ -55,6 +55,12 @@ const onPriceChange = (price: number) => {
<style lang="less">
.price-input {
width: 100%;
.price-input-number {
width: 100%;
}
.price-text {
color: red;
}