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
@@ -1,9 +1,9 @@
<template> <template>
<fs-page class="page-cert-dns-persist"> <fs-page class="page-cert-dns-persist">
<template #header> <template #header>
<div> <div class="title">
<div class="title">DNS持久验证记录</div> DNS持久验证记录
<div class="text-orange-500 mt-5">当前仅 Let's Encrypt 测试环境可以申请 DNS 持久验证证书</div> <span class="red sub" style="color: red">当前仅 Let's Encrypt 测试环境可以申请 DNS 持久验证证书</span>
</div> </div>
</template> </template>
<fs-crud ref="crudRef" v-bind="crudBinding"></fs-crud> <fs-crud ref="crudRef" v-bind="crudBinding"></fs-crud>
@@ -32,7 +32,9 @@
<div class="invite-info-row"> <div class="invite-info-row">
<span class="info-label">我的等级</span> <span class="info-label">我的等级</span>
<a-button type="link" class="level-button" @click="levelDialogOpen = true"> <a-button type="link" class="level-button" @click="levelDialogOpen = true">
<span v-if="inviteInfo.currentLevel" class="level-medal" :class="levelMedalClass(inviteInfo.currentLevel)">{{ levelMedal(inviteInfo.currentLevel) }}</span> <span v-if="inviteInfo.currentLevel" class="level-medal">
<fs-icon :icon="levelIcon(inviteInfo.currentLevel)" />
</span>
<span>{{ inviteInfo.currentLevel?.name || "未设置" }}</span> <span>{{ inviteInfo.currentLevel?.name || "未设置" }}</span>
<span v-if="inviteInfo.currentLevel" class="current-level-rate">{{ inviteInfo.currentLevel.commissionRate }}%</span> <span v-if="inviteInfo.currentLevel" class="current-level-rate">{{ inviteInfo.currentLevel.commissionRate }}%</span>
</a-button> </a-button>
@@ -61,9 +63,11 @@
<div class="level-card-grid modal-level-grid"> <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 }">
<div class="level-name"> <div class="level-name">
<span class="level-medal" :class="levelMedalClass(level)">{{ levelMedal(level) }}</span> <span class="level-medal">
<fs-icon :icon="levelIcon(level)" />
</span>
{{ level.name }} {{ level.name }}
<a-tag v-if="level.isHidden" color="orange">专属</a-tag> <a-tag v-if="level.levelType === 'exclusive'" color="orange">专属</a-tag>
</div> </div>
<div class="level-rate-label">佣金比例</div> <div class="level-rate-label">佣金比例</div>
<div class="level-rate">{{ level.commissionRate }}%</div> <div class="level-rate">{{ level.commissionRate }}%</div>
@@ -86,7 +90,8 @@
@ok="handleAgreementOk" @ok="handleAgreementOk"
@cancel="closeAgreementDialog" @cancel="closeAgreementDialog"
> >
<div class="invite-agreement-content">{{ agreementText }}</div> <!-- eslint-disable-next-line vue/no-v-html -->
<div class="invite-agreement-content editor-content-view" v-html="agreementText"></div>
<div v-if="agreementDialogNeedOpen" class="invite-agreement-confirm"> <div v-if="agreementDialogNeedOpen" class="invite-agreement-confirm">
<a-checkbox v-model:checked="agreementAgree">我已阅读并同意推广协议</a-checkbox> <a-checkbox v-model:checked="agreementAgree">我已阅读并同意推广协议</a-checkbox>
</div> </div>
@@ -119,7 +124,7 @@ const agreementDialogOpen = ref(false);
const agreementDialogNeedOpen = ref(false); const agreementDialogNeedOpen = ref(false);
const agreementAgree = ref(false); const agreementAgree = ref(false);
const agreementSubmitting = ref(false); const agreementSubmitting = ref(false);
const defaultAgreementContent = "请遵守平台推广规则,不得通过虚假注册、刷单、恶意诱导等方式获取收益。平台有权对异常推广行为进行核查,并根据实际情况暂停结算或关闭激励计划资格。"; const defaultAgreementContent = "<p>请遵守平台推广规则,不得通过虚假注册、刷单、恶意诱导等方式获取收益。平台有权对异常推广行为进行核查,并根据实际情况暂停结算或关闭激励计划资格。</p>";
const inviteInfo = reactive<any>({ const inviteInfo = reactive<any>({
enabled: false, enabled: false,
@@ -170,38 +175,8 @@ const visibleLevels = computed(() => {
const agreementText = computed(() => inviteInfo.agreementContent?.trim() || defaultAgreementContent); const agreementText = computed(() => inviteInfo.agreementContent?.trim() || defaultAgreementContent);
function levelMedal(level: any) { function levelIcon(level: any) {
const name = `${level?.name || ""}`; return level?.icon || "ion:ribbon-outline";
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) { function openAgreementDialog(needOpenPlan: boolean) {
@@ -419,33 +394,10 @@ onActivated(async () => {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 20px; width: 22px;
height: 20px; height: 22px;
border-radius: 50%;
background: #f4d7a1;
color: #8a5a16; color: #8a5a16;
font-size: 12px; font-size: 20px;
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 { .invite-tabs {
@@ -520,33 +472,10 @@ onActivated(async () => {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 20px; width: 22px;
height: 20px; height: 22px;
border-radius: 50%;
background: #f4d7a1;
color: #8a5a16; color: #8a5a16;
font-size: 12px; font-size: 20px;
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 { .level-rate-label {
@@ -591,11 +520,19 @@ onActivated(async () => {
max-height: 360px; max-height: 360px;
padding: 12px; padding: 12px;
overflow: auto; overflow: auto;
white-space: pre-wrap;
border: 1px solid #eee; border: 1px solid #eee;
border-radius: 6px; border-radius: 6px;
background: hsl(var(--card)); background: hsl(var(--card));
line-height: 1.7; line-height: 1.7;
:deep(img) {
max-width: 100%;
height: auto;
}
:deep(p) {
margin-bottom: 10px;
}
} }
.invite-agreement-confirm { .invite-agreement-confirm {
@@ -8,6 +8,10 @@ export async function GetWithdrawSetting() {
return await request({ url: "/wallet/withdraw/setting/get", method: "post" }); return await request({ url: "/wallet/withdraw/setting/get", method: "post" });
} }
export async function GetWalletSetting() {
return await request({ url: "/wallet/settings/get", method: "post" });
}
export async function SaveWithdrawSetting(data: any) { export async function SaveWithdrawSetting(data: any) {
return await request({ url: "/wallet/withdraw/setting/save", method: "post", data }); return await request({ url: "/wallet/withdraw/setting/save", method: "post", data });
} }
@@ -1,12 +1,6 @@
import { CreateCrudOptionsRet, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud"; import { CreateCrudOptionsRet, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import * as api from "./api"; import * as api from "./api";
import PriceInput from "/@/views/sys/suite/product/price-input.vue"; import PriceInput from "/@/views/sys/suite/product/price-input.vue";
import { useUserStore } from "/@/store/user";
function buildPrivateFileUrl(key: string) {
const userStore = useUserStore();
return `/api/basic/file/download?token=${userStore.getToken}&key=${encodeURIComponent(key)}`;
}
export default function (): CreateCrudOptionsRet { export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => { const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
@@ -53,22 +47,6 @@ export default function (): CreateCrudOptionsRet {
}), }),
column: { width: 110 }, column: { width: 110 },
}, },
realName: { title: "真实姓名", type: "text", column: { width: 120 } },
account: { title: "收款账号", type: "text", column: { width: 180 } },
bankName: { title: "开户银行", type: "text", column: { width: 160 } },
qrCode: {
title: "收款二维码",
type: "text",
column: {
width: 120,
cellRender({ value }) {
if (!value) {
return "-";
}
return <a-image src={buildPrivateFileUrl(value)} width={48} />;
},
},
},
auditRemark: { title: "审核备注", type: "text", column: { minWidth: 180 } }, auditRemark: { title: "审核备注", type: "text", column: { minWidth: 180 } },
}, },
}, },
@@ -6,19 +6,11 @@
<div class="wallet-body"> <div class="wallet-body">
<div class="wallet-summary-grid"> <div class="wallet-summary-grid">
<div v-for="item in summaryCards" :key="item.key" class="summary-card"> <div v-for="item in summaryCards" :key="item.key" class="summary-card">
<div class="summary-title">{{ item.title }}</div> <div class="summary-card-main">
<div class="summary-value" :class="item.className">{{ item.value }}</div> <div class="summary-title">{{ item.title }}</div>
</div> <div class="summary-value" :class="item.className">{{ item.value }}</div>
</div>
<div class="wallet-action-panel">
<div class="wallet-action-title">提现操作</div>
<div class="wallet-action-content">
<a-button class="wallet-action-button" type="primary" @click="openWithdrawSetting">提现设置</a-button>
<div class="withdraw-amount-field">
<a-input-number v-model:value="withdrawAmountYuan" class="withdraw-amount-input" :min="0" addon-before="提现金额" addon-after="" />
</div> </div>
<a-button class="wallet-action-button" type="primary" @click="applyWithdraw">申请提现</a-button> <a-button v-if="item.key === 'availableAmount'" class="summary-action-button" type="primary" @click="openWithdrawDialog">申请提现</a-button>
</div> </div>
</div> </div>
@@ -35,9 +27,9 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onActivated, onMounted, reactive, ref } from "vue"; import { computed, h, onActivated, onMounted, reactive, ref } from "vue";
import { compute, dict, useFs } from "@fast-crud/fast-crud"; import { compute, dict, useFs } from "@fast-crud/fast-crud";
import { notification } from "ant-design-vue"; import { Button, notification } from "ant-design-vue";
import * as api from "./api"; import * as api from "./api";
import createLogsCrudOptions from "./crud-logs"; import createLogsCrudOptions from "./crud-logs";
import createWithdrawCrudOptions from "./crud-withdraw"; import createWithdrawCrudOptions from "./crud-withdraw";
@@ -48,7 +40,6 @@ import { useUserStore } from "/@/store/user";
defineOptions({ name: "MyWallet" }); defineOptions({ name: "MyWallet" });
const summary = reactive<any>({ availableAmount: 0, frozenAmount: 0, totalIncomeAmount: 0, totalWithdrawAmount: 0 }); const summary = reactive<any>({ availableAmount: 0, frozenAmount: 0, totalIncomeAmount: 0, totalWithdrawAmount: 0 });
const withdrawAmountYuan = ref(0);
const loaded = ref(false); const loaded = ref(false);
const activeTab = ref("withdraw"); const activeTab = ref("withdraw");
const { openFormDialog } = useFormDialog(); const { openFormDialog } = useFormDialog();
@@ -101,8 +92,18 @@ async function loadWalletSummary() {
} }
async function openWithdrawSetting() { async function openWithdrawSetting() {
const setting: any = await api.GetWithdrawSetting(); const [setting, walletSetting]: any[] = await Promise.all([api.GetWithdrawSetting(), api.GetWalletSetting()]);
const enabledChannels = walletSetting?.withdrawChannels?.length ? walletSetting.withdrawChannels : ["alipay", "bank"];
const enabledBanks = walletSetting?.withdrawBanks?.length ? walletSetting.withdrawBanks : [];
const channelOptions = [
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
].filter(item => enabledChannels.includes(item.value));
const bankOptions = enabledBanks.map((item: string) => ({ label: item, value: item }));
const initialForm = Object.assign({ channel: "alipay", realName: "", account: "", bankName: "" }, setting || {}); const initialForm = Object.assign({ channel: "alipay", realName: "", account: "", bankName: "" }, setting || {});
if (!enabledChannels.includes(initialForm.channel)) {
initialForm.channel = enabledChannels[0] || "alipay";
}
await openFormDialog({ await openFormDialog({
title: "提现设置", title: "提现设置",
wrapper: { wrapper: {
@@ -114,10 +115,7 @@ async function openWithdrawSetting() {
title: "提现渠道", title: "提现渠道",
type: "dict-radio", type: "dict-radio",
dict: dict({ dict: dict({
data: [ data: channelOptions,
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
],
}), }),
form: { form: {
col: { span: 24 }, col: { span: 24 },
@@ -142,19 +140,13 @@ async function openWithdrawSetting() {
}, },
qrCode: { qrCode: {
title: "收款二维码", title: "收款二维码",
type: "cropper-uploader", type: "avatar-uploader",
form: { form: {
col: { span: 24 }, col: { span: 24 },
helper: "上传支付宝收款二维码图片",
show: compute(({ form }) => form.channel !== "bank"), show: compute(({ form }) => form.channel !== "bank"),
component: { component: {
vModel: "modelValue",
valueType: "key", valueType: "key",
cropper: {
aspectRatio: 1,
autoCropArea: 1,
viewMode: 0,
},
onReady: null,
uploader: { uploader: {
type: "form", type: "form",
action: "/basic/file/upload?token=" + userStore.getToken, action: "/basic/file/upload?token=" + userStore.getToken,
@@ -174,10 +166,16 @@ async function openWithdrawSetting() {
}, },
bankName: { bankName: {
title: "开户银行", title: "开户银行",
type: "text",
form: { form: {
col: { span: 24 }, col: { span: 24 },
show: compute(({ form }) => form.channel === "bank"), show: compute(({ form }) => form.channel === "bank"),
component: {
name: "a-select",
vModel: "value",
options: bankOptions,
showSearch: true,
placeholder: "请选择开户银行",
},
rules: [{ required: compute(({ form }) => form.channel === "bank"), message: "请输入开户银行" }], rules: [{ required: compute(({ form }) => form.channel === "bank"), message: "请输入开户银行" }],
}, },
}, },
@@ -192,9 +190,54 @@ async function openWithdrawSetting() {
}); });
} }
async function applyWithdraw() { async function openWithdrawDialog() {
await api.ApplyWithdraw(util.amount.toCent(withdrawAmountYuan.value || 0)); await openFormDialog({
withdrawAmountYuan.value = 0; title: "申请提现",
wrapper: {
width: 520,
},
initialForm: {
amountYuan: null,
},
body: () =>
h("div", { class: "withdraw-dialog-tip" }, [
h("span", "提现前需要先设置提现账号。"),
h(
Button,
{
size: "small",
type: "link",
onClick: openWithdrawSetting,
},
() => "提现设置"
),
]),
columns: {
amountYuan: {
title: "提现金额",
form: {
col: { span: 24 },
component: {
name: "a-input-number",
vModel: "value",
min: 0,
precision: 2,
addonAfter: "元",
style: { width: "100%" },
},
rules: [{ required: true, message: "请输入提现金额" }],
},
},
},
async onSubmit(form: any) {
await applyWithdraw(form.amountYuan);
},
});
}
async function applyWithdraw(amountYuan: number) {
await api.ApplyWithdraw(util.amount.toCent(amountYuan || 0));
activeTab.value = "withdraw";
await loadWalletSummary(); await loadWalletSummary();
await Promise.all([withdrawCrudExpose.doRefresh(), logsCrudExpose.doRefresh()]); await Promise.all([withdrawCrudExpose.doRefresh(), logsCrudExpose.doRefresh()]);
notification.success({ message: "提现申请已提交" }); notification.success({ message: "提现申请已提交" });
@@ -243,8 +286,7 @@ onActivated(async () => {
background: hsl(var(--background-deep)); background: hsl(var(--background-deep));
} }
.wallet-summary-grid, .wallet-summary-grid {
.wallet-action-panel {
flex: none; flex: none;
} }
@@ -256,7 +298,6 @@ onActivated(async () => {
} }
.summary-card, .summary-card,
.wallet-action-panel,
.wallet-tabs { .wallet-tabs {
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
border-radius: 8px; border-radius: 8px;
@@ -265,10 +306,18 @@ onActivated(async () => {
} }
.summary-card { .summary-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 112px; min-height: 112px;
padding: 22px; padding: 22px;
} }
.summary-card-main {
min-width: 0;
}
.summary-title { .summary-title {
margin-bottom: 10px; margin-bottom: 10px;
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
@@ -294,45 +343,8 @@ onActivated(async () => {
color: #3478f6; color: #3478f6;
} }
.wallet-action-panel { .summary-action-button {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 16px;
padding: 14px 18px;
margin-bottom: 18px;
}
.wallet-action-title {
flex: none; flex: none;
color: hsl(var(--foreground));
font-size: 15px;
font-weight: 600;
}
.wallet-action-content {
display: flex;
align-items: center;
flex: 1;
flex-wrap: wrap;
justify-content: flex-start;
gap: 10px;
min-width: 0;
}
.wallet-action-button {
flex: none;
}
.withdraw-amount-field {
flex: 0 1 280px;
min-width: 240px;
}
.withdraw-amount-field,
.withdraw-amount-input,
.withdraw-amount-input.ant-input-number-group-wrapper {
width: 100%;
} }
.wallet-tabs { .wallet-tabs {
@@ -373,24 +385,23 @@ onActivated(async () => {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.wallet-action-panel { .summary-card {
align-items: stretch; align-items: flex-start;
flex-direction: column; flex-direction: column;
} }
.wallet-action-content {
justify-content: flex-start;
}
.wallet-action-button,
.withdraw-amount-field {
width: 100%;
}
.withdraw-amount-field {
flex-basis: auto;
min-width: 0;
}
} }
} }
.withdraw-dialog-tip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
margin-bottom: 12px;
border: 1px solid #d9e8ff;
border-radius: 6px;
background: #f5f9ff;
color: #315174;
}
</style> </style>
@@ -24,6 +24,10 @@ export async function UpdateLevel(data: any) {
return await request({ url: "/sys/invite/level/update", method: "post", data }); 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) { export async function GetUserLevels(query: any) {
return await request({ url: "/sys/invite/user/page", method: "post", data: query }); 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 { export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => { const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
query.sort = { prop: "sort", asc: true };
return await api.GetLevels(query); return await api.GetLevels(query);
}; };
const addRequest = async ({ form }: AddReq) => { const addRequest = async ({ form }: AddReq) => {
@@ -14,8 +15,7 @@ export default function (): CreateCrudOptionsRet {
return await api.UpdateLevel(form); return await api.UpdateLevel(form);
}; };
const delRequest = async ({ row }: DelReq) => { const delRequest = async ({ row }: DelReq) => {
row.disabled = true; return await api.DeleteLevel(row.id);
return await api.UpdateLevel(row);
}; };
return { return {
@@ -49,6 +49,25 @@ export default function (): CreateCrudOptionsRet {
}, },
column: { width: 140 }, 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: { minAmount: {
title: "升级金额", title: "升级金额",
type: "number", type: "number",
@@ -70,23 +89,29 @@ export default function (): CreateCrudOptionsRet {
}, },
column: { width: 110, align: "center", cellRender: ({ value }) => `${value || 0}%` }, column: { width: 110, align: "center", cellRender: ({ value }) => `${value || 0}%` },
}, },
isHidden: { levelType: {
title: "隐藏等级", title: "等级类型",
type: "dict-switch", type: "dict-radio",
dict: dict({ dict: dict({
data: [ data: [
{ label: "普通等级", value: false, color: "success" }, { label: "普通等级", value: "normal", color: "success" },
{ label: "隐藏等级", value: true, color: "warning" }, { label: "专属等级", value: "exclusive", color: "warning" },
], ],
}), }),
form: { value: false }, form: {
value: "normal",
helper: "专属等级可由管理员手动指定,不参与普通用户自动升级。",
},
column: { width: 120, align: "center" }, column: { width: 120, align: "center" },
}, },
sort: { sort: {
title: "排序", title: "排序",
type: "number", type: "number",
form: { value: 10 }, form: {
column: { width: 90, align: "center" }, value: 10,
helper: "排序号越小越靠前。",
},
column: { width: 90, align: "center", sorter: true },
}, },
disabled: { disabled: {
title: "状态", title: "状态",
@@ -46,7 +46,7 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
}), }),
form: { form: {
col: { span: 24 }, col: { span: 24 },
helper: "隐藏等级会自动锁定,不参与自动升级。", helper: "专属等级会自动锁定,不参与自动升级。",
}, },
}, },
}, },
@@ -111,13 +111,13 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
}), }),
column: { width: 110, align: "center" }, column: { width: 110, align: "center" },
}, },
isHidden: { levelType: {
title: "隐藏等级", title: "等级类型",
type: "dict-switch", type: "dict-select",
dict: dict({ dict: dict({
data: [ data: [
{ label: "", value: false, color: "default" }, { label: "普通等级", value: "normal", color: "success" },
{ label: "", value: true, color: "warning" }, { label: "专属等级", value: "exclusive", color: "warning" },
], ],
}), }),
column: { width: 100, align: "center", show: compute(({ row }) => row.levelId) }, column: { width: 100, align: "center", show: compute(({ row }) => row.levelId) },
@@ -18,16 +18,17 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
}; };
function renderWithdrawDetail(row: any) { function renderWithdrawDetail(row: any) {
const isBank = row.channel === "bank";
return ( 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.channel === "bank" ? "银行卡" : "支付宝"}</a-descriptions-item>
<a-descriptions-item label="用户名">{row.userDisplay || row.userId}</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.account || "-"}</a-descriptions-item>
<a-descriptions-item label="开户行名称">{row.bankName || "-"}</a-descriptions-item> {isBank ? <a-descriptions-item label="开户行名称">{row.bankName || "-"}</a-descriptions-item> : null}
<a-descriptions-item label="收款二维码" span={2}> {!isBank ? <a-descriptions-item label="收款二维码">{row.qrCode ? <a-image src={buildPrivateFileUrl(row.qrCode)} width={160} /> : <span>-</span>}</a-descriptions-item> : null}
{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>
</a-descriptions> </a-descriptions>
); );
} }
@@ -37,6 +38,11 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
title: "提现审核", title: "提现审核",
wrapper: { wrapper: {
width: 760, width: 760,
buttons: {
ok: {
text: "确认已转账完成",
},
},
}, },
body: () => renderWithdrawDetail(row), body: () => renderWithdrawDetail(row),
onSubmit: async () => { onSubmit: async () => {
@@ -153,17 +159,16 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
}, },
realName: { title: "真实姓名", type: "text", search: { show: true }, column: { width: 120 } }, realName: { title: "真实姓名", type: "text", search: { show: true }, column: { width: 120 } },
account: { title: "收款账号", type: "text", column: { width: 180 } }, account: { title: "收款账号", type: "text", column: { width: 180 } },
bankName: { title: "开户银行", type: "text", column: { width: 160 } }, bankName: {
qrCode: { title: "开户银行",
title: "收款二维码",
type: "text", type: "text",
column: { column: {
width: 120, width: 160,
cellRender({ value }) { cellRender({ row, value }) {
if (!value) { if (row.channel !== "bank") {
return "-"; return "-";
} }
return <a-image src={buildPrivateFileUrl(value)} width={48} />; return value || "-";
}, },
}, },
}, },
@@ -3,18 +3,94 @@
<template #header> <template #header>
<div class="title">推广等级</div> <div class="title">推广等级</div>
</template> </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> </fs-page>
</template> </template>
<script lang="ts" setup> <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 { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud-level"; import createCrudOptions from "./crud-level";
import * as api from "./api";
import { util } from "/@/utils";
defineOptions({ name: "SysInviteLevel" }); defineOptions({ name: "SysInviteLevel" });
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions }); 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(() => { onMounted(() => {
crudExpose.doRefresh(); crudExpose.doRefresh();
@@ -23,3 +99,130 @@ onActivated(() => {
crudExpose.doRefresh(); crudExpose.doRefresh();
}); });
</script> </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-form-item label="开启激励计划" name="enabled">
<a-switch v-model:checked="settings.enabled" /> <a-switch v-model:checked="settings.enabled" />
</a-form-item> </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-form-item label="最低提现金额" name="minWithdrawAmountYuan">
<a-input-number v-model:value="settings.minWithdrawAmountYuan" :min="0" addon-after="" /> <a-input-number v-model:value="settings.minWithdrawAmountYuan" :min="0" addon-after="" />
</a-form-item> </a-form-item>
<a-form-item label="提现渠道" name="withdrawChannels"> <a-form-item label="提现渠道" name="withdrawChannels">
<a-checkbox-group v-model:value="settings.withdrawChannels" :options="withdrawChannelOptions" /> <a-checkbox-group v-model:value="settings.withdrawChannels" :options="withdrawChannelOptions" />
</a-form-item> </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-button type="primary" @click="saveSettings">保存设置</a-button>
</a-form-item> </a-form-item>
</a-form> </a-form>
@@ -26,20 +36,55 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, reactive } from "vue"; import { computed, onMounted, reactive } from "vue";
import { notification } from "ant-design-vue"; import { notification } from "ant-design-vue";
import * as api from "./api"; import * as api from "./api";
import { util } from "/@/utils"; import { util } from "/@/utils";
import { useSettingStore } from "/@/store/settings"; import { useSettingStore } from "/@/store/settings";
import { useUserStore } from "/@/store/user";
defineOptions({ name: "SysInviteCommissionSetting" }); defineOptions({ name: "SysInviteCommissionSetting" });
const defaultAgreement = "请遵守平台推广规则,不得通过虚假注册、刷单、恶意诱导等方式获取收益。平台有权对异常推广行为进行核查,并根据实际情况暂停结算或关闭激励计划资格。"; const defaultAgreement = "<p>请遵守平台推广规则,不得通过虚假注册、刷单、恶意诱导等方式获取收益。平台有权对异常推广行为进行核查,并根据实际情况暂停结算或关闭激励计划资格。</p>";
const settings = reactive<any>({ enabled: false, agreementContent: "", minWithdrawAmountYuan: 0, withdrawChannels: ["alipay", "bank"] }); const defaultWithdrawBanks = [
"中国工商银行",
"中国农业银行",
"中国银行",
"中国建设银行",
"交通银行",
"招商银行",
"中国邮政储蓄银行",
"中信银行",
"中国光大银行",
"华夏银行",
"中国民生银行",
"广发银行",
"平安银行",
"兴业银行",
"浦发银行",
];
const settings = reactive<any>({ enabled: false, agreementContent: "", minWithdrawAmountYuan: 0, withdrawChannels: ["alipay", "bank"], withdrawBanks: defaultWithdrawBanks });
const withdrawChannelOptions = [ const withdrawChannelOptions = [
{ label: "支付宝", value: "alipay" }, { label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" }, { 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() { async function loadSettings() {
const data: any = await api.GetSettings(); const data: any = await api.GetSettings();
@@ -47,19 +92,34 @@ async function loadSettings() {
settings.agreementContent = data?.agreementContent || defaultAgreement; settings.agreementContent = data?.agreementContent || defaultAgreement;
settings.minWithdrawAmountYuan = util.amount.toYuan(data?.minWithdrawAmount || 0); settings.minWithdrawAmountYuan = util.amount.toYuan(data?.minWithdrawAmount || 0);
settings.withdrawChannels = data?.withdrawChannels?.length ? data.withdrawChannels : ["alipay", "bank"]; settings.withdrawChannels = data?.withdrawChannels?.length ? data.withdrawChannels : ["alipay", "bank"];
settings.withdrawBanks = data?.withdrawBanks?.length ? data.withdrawBanks : defaultWithdrawBanks;
} }
async function saveSettings() { 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({ await api.SaveSettings({
enabled: settings.enabled, enabled: settings.enabled,
agreementContent: settings.agreementContent || "", agreementContent: settings.agreementContent || "",
minWithdrawAmount: util.amount.toCent(settings.minWithdrawAmountYuan || 0), minWithdrawAmount: util.amount.toCent(settings.minWithdrawAmountYuan || 0),
withdrawChannels: settings.withdrawChannels || [], withdrawChannels: settings.withdrawChannels || [],
withdrawBanks,
}); });
await useSettingStore().loadSysSettings(); await useSettingStore().loadSysSettings();
notification.success({ message: "保存成功" }); notification.success({ message: "保存成功" });
} }
function isBlankAgreement(content: string) {
const text = `${content || ""}`
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, "")
.trim();
return !text;
}
onMounted(loadSettings); onMounted(loadSettings);
</script> </script>
@@ -71,5 +131,8 @@ onMounted(loadSettings);
.settings-form { .settings-form {
max-width: 860px; max-width: 860px;
} }
.agreement-editor {
min-height: 420px;
}
} }
</style> </style>
@@ -1,6 +1,6 @@
<template> <template>
<div class="flex-o price-input"> <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> <span v-else class="price-text" :style="style">{{ priceLabel }}</span>
</div> </div>
</template> </template>
@@ -55,6 +55,12 @@ const onPriceChange = (price: number) => {
<style lang="less"> <style lang="less">
.price-input { .price-input {
width: 100%;
.price-input-number {
width: 100%;
}
.price-text { .price-text {
color: red; color: red;
} }
@@ -5,10 +5,11 @@ CREATE TABLE `cd_invite_level`
( (
`id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL, `id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL,
`name` varchar(100), `name` varchar(100),
`icon` varchar(100) NOT NULL DEFAULT 'fluent-emoji-flat:2nd-place-medal',
`sort` bigint NOT NULL DEFAULT 0, `sort` bigint NOT NULL DEFAULT 0,
`min_amount` bigint NOT NULL DEFAULT 0, `min_amount` bigint NOT NULL DEFAULT 0,
`commission_rate` bigint NOT NULL DEFAULT 0, `commission_rate` bigint NOT NULL DEFAULT 0,
`is_hidden` boolean NOT NULL DEFAULT false, `level_type` varchar(30) NOT NULL DEFAULT 'normal',
`disabled` boolean NOT NULL DEFAULT false, `disabled` boolean NOT NULL DEFAULT false,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
@@ -28,8 +29,8 @@ CREATE TABLE `cd_invite_user_plan`
); );
CREATE UNIQUE INDEX `index_invite_user_plan_user_id` ON `cd_invite_user_plan` (`user_id`); CREATE UNIQUE INDEX `index_invite_user_plan_user_id` ON `cd_invite_user_plan` (`user_id`);
INSERT INTO `cd_invite_level` (`name`, `sort`, `min_amount`, `commission_rate`, `is_hidden`, `disabled`) INSERT INTO `cd_invite_level` (`name`, `icon`, `sort`, `min_amount`, `commission_rate`, `level_type`, `disabled`)
VALUES ('青铜', 10, 0, 10, false, false), VALUES ('青铜', 'fluent-emoji-flat:2nd-place-medal', 10, 0, 10, 'normal', false),
('白银', 20, 100000, 15, false, false), ('白银', 'fluent-emoji-flat:1st-place-medal', 20, 100000, 15, 'normal', false),
('黄金', 30, 500000, 20, false, false), ('黄金', 'fluent-emoji-flat:3rd-place-medal', 30, 500000, 20, 'normal', false),
('钻石', 40, 1000000, 30, false, false); ('钻石', 'streamline-color:diamond-2', 40, 1000000, 30, 'normal', false);
@@ -5,10 +5,11 @@ CREATE TABLE "cd_invite_level"
( (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL, "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"name" varchar(100), "name" varchar(100),
"icon" varchar(100) NOT NULL DEFAULT 'fluent-emoji-flat:2nd-place-medal',
"sort" bigint NOT NULL DEFAULT 0, "sort" bigint NOT NULL DEFAULT 0,
"min_amount" bigint NOT NULL DEFAULT 0, "min_amount" bigint NOT NULL DEFAULT 0,
"commission_rate" bigint NOT NULL DEFAULT 0, "commission_rate" bigint NOT NULL DEFAULT 0,
"is_hidden" boolean NOT NULL DEFAULT (false), "level_type" varchar(30) NOT NULL DEFAULT 'normal',
"disabled" boolean NOT NULL DEFAULT (false), "disabled" boolean NOT NULL DEFAULT (false),
"create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP), "create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP) "update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP)
@@ -28,8 +29,8 @@ CREATE TABLE "cd_invite_user_plan"
); );
CREATE UNIQUE INDEX "index_invite_user_plan_user_id" ON "cd_invite_user_plan" ("user_id"); CREATE UNIQUE INDEX "index_invite_user_plan_user_id" ON "cd_invite_user_plan" ("user_id");
INSERT INTO "cd_invite_level" ("name", "sort", "min_amount", "commission_rate", "is_hidden", "disabled") INSERT INTO "cd_invite_level" ("name", "icon", "sort", "min_amount", "commission_rate", "level_type", "disabled")
VALUES ('青铜', 10, 0, 10, false, false), VALUES ('青铜', 'fluent-emoji-flat:2nd-place-medal', 10, 0, 10, 'normal', false),
('白银', 20, 100000, 15, false, false), ('白银', 'fluent-emoji-flat:1st-place-medal', 20, 100000, 15, 'normal', false),
('黄金', 30, 500000, 20, false, false), ('黄金', 'fluent-emoji-flat:3rd-place-medal', 30, 500000, 20, 'normal', false),
('钻石', 40, 1000000, 30, false, false); ('钻石', 'streamline-color:diamond-2', 40, 1000000, 30, 'normal', false);
@@ -5,10 +5,11 @@ CREATE TABLE "cd_invite_level"
( (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"name" varchar(100), "name" varchar(100),
"icon" varchar(100) NOT NULL DEFAULT 'fluent-emoji-flat:2nd-place-medal',
"sort" integer NOT NULL DEFAULT 0, "sort" integer NOT NULL DEFAULT 0,
"min_amount" integer NOT NULL DEFAULT 0, "min_amount" integer NOT NULL DEFAULT 0,
"commission_rate" integer NOT NULL DEFAULT 0, "commission_rate" integer NOT NULL DEFAULT 0,
"is_hidden" boolean NOT NULL DEFAULT (false), "level_type" varchar(30) NOT NULL DEFAULT 'normal',
"disabled" boolean NOT NULL DEFAULT (false), "disabled" boolean NOT NULL DEFAULT (false),
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP) "update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
@@ -28,8 +29,8 @@ CREATE TABLE "cd_invite_user_plan"
); );
CREATE UNIQUE INDEX "index_invite_user_plan_user_id" ON "cd_invite_user_plan" ("user_id"); CREATE UNIQUE INDEX "index_invite_user_plan_user_id" ON "cd_invite_user_plan" ("user_id");
INSERT INTO "cd_invite_level" ("name", "sort", "min_amount", "commission_rate", "is_hidden", "disabled") INSERT INTO "cd_invite_level" ("name", "icon", "sort", "min_amount", "commission_rate", "level_type", "disabled")
VALUES ('青铜', 10, 0, 10, false, false), VALUES ('青铜', 'fluent-emoji-flat:2nd-place-medal', 10, 0, 10, 'normal', false),
('白银', 20, 100000, 15, false, false), ('白银', 'fluent-emoji-flat:1st-place-medal', 20, 100000, 15, 'normal', false),
('黄金', 30, 500000, 20, false, false), ('黄金', 'fluent-emoji-flat:3rd-place-medal', 30, 500000, 20, 'normal', false),
('钻石', 40, 1000000, 30, false, false); ('钻石', 'streamline-color:diamond-2', 40, 1000000, 30, 'normal', false);
@@ -137,6 +137,7 @@ export class MainConfiguration {
}); });
logger.info('当前环境:', this.app.getEnv()); // prod logger.info('当前环境:', this.app.getEnv()); // prod
// throw new Error("address family not supported")
} }
} }
@@ -3,7 +3,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { getImageDownloadOptions, isImageFile } from "./file-controller.js"; import { FileController, getImageDownloadOptions, isImageFile } from "./file-controller.js";
describe("FileController.isImageFile", () => { describe("FileController.isImageFile", () => {
it("detects uploaded logo image files", () => { it("detects uploaded logo image files", () => {
@@ -37,3 +37,23 @@ describe("FileController.isImageFile", () => {
assert.equal(getImageDownloadOptions("data/upload/private/user/cert.pem"), undefined); assert.equal(getImageDownloadOptions("data/upload/private/user/cert.pem"), undefined);
}); });
}); });
describe("FileController.upload", () => {
it("auto saves uploaded file to public directory when autoSave is true", async () => {
const controller = new FileController();
controller.fileService = {
async saveFile(userId: number, key: string, permission: string) {
assert.equal(userId, 1);
assert.equal(permission, "public");
assert.equal(key.startsWith("tmpfile_key_"), true);
return "/public/1/logo.png";
},
} as any;
controller.ctx = { user: { id: 1 } } as any;
const res = await controller.upload([{ filename: "logo.png", data: "tmp/logo.png" }] as any, {}, "true");
assert.equal(res.data.key, "/public/1/logo.png");
assert.equal(res.data.url, "/api/basic/file/download?key=%2Fpublic%2F1%2Flogo.png");
});
});
@@ -37,7 +37,7 @@ export class FileController extends BaseController {
authService: AuthService; authService: AuthService;
@Post('/upload', { description: Constants.per.authOnly }) @Post('/upload', { description: Constants.per.authOnly })
async upload(@Files() files: UploadFileInfo<string>[], @Fields() fields: any) { async upload(@Files() files: UploadFileInfo<string>[], @Fields() fields: any, @Query('autoSave') autoSave: string) {
console.log('files', files, fields); console.log('files', files, fields);
const cacheKey = uploadTmpFileCacheKey + nanoid(); const cacheKey = uploadTmpFileCacheKey + nanoid();
const file = files[0]; const file = files[0];
@@ -51,6 +51,13 @@ export class FileController extends BaseController {
ttl: 1000 * 60 * 60, ttl: 1000 * 60 * 60,
} }
); );
if (autoSave === 'true') {
const key = await this.fileService.saveFile(this.getUserId(), cacheKey, 'public');
return this.ok({
key,
url: `/api/basic/file/download?key=${encodeURIComponent(key)}`,
});
}
return this.ok({ return this.ok({
key: cacheKey, key: cacheKey,
}); });