ss
Some checks failed
Deploy Website on push / Deploy Push Element Ftp (push) Waiting to run
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Some checks failed
Deploy Website on push / Deploy Push Element Ftp (push) Waiting to run
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
This commit is contained in:
3
apps/web-antd/src/views/_core/README.md
Normal file
3
apps/web-antd/src/views/_core/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# \_core
|
||||
|
||||
此目录包含应用程序正常运行所需的基本视图。这些视图是应用程序布局中使用的视图。
|
||||
9
apps/web-antd/src/views/_core/about/index.vue
Normal file
9
apps/web-antd/src/views/_core/about/index.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { About } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'About' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<About />
|
||||
</template>
|
||||
69
apps/web-antd/src/views/_core/authentication/code-login.vue
Normal file
69
apps/web-antd/src/views/_core/authentication/code-login.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { AuthenticationCodeLogin, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
defineOptions({ name: 'CodeLogin' });
|
||||
|
||||
const loading = ref(false);
|
||||
const CODE_LENGTH = 6;
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.mobile'),
|
||||
},
|
||||
fieldName: 'phoneNumber',
|
||||
label: $t('authentication.mobile'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, { message: $t('authentication.mobileTip') })
|
||||
.refine((v) => /^\d{11}$/.test(v), {
|
||||
message: $t('authentication.mobileErrortip'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
component: 'VbenPinInput',
|
||||
componentProps: {
|
||||
codeLength: CODE_LENGTH,
|
||||
createText: (countdown: number) => {
|
||||
const text =
|
||||
countdown > 0
|
||||
? $t('authentication.sendText', [countdown])
|
||||
: $t('authentication.sendCode');
|
||||
return text;
|
||||
},
|
||||
placeholder: $t('authentication.code'),
|
||||
},
|
||||
fieldName: 'code',
|
||||
label: $t('authentication.code'),
|
||||
rules: z.string().length(CODE_LENGTH, {
|
||||
message: $t('authentication.codeTip', [CODE_LENGTH]),
|
||||
}),
|
||||
},
|
||||
];
|
||||
});
|
||||
/**
|
||||
* 异步处理登录操作
|
||||
* Asynchronously handle the login process
|
||||
* @param values 登录表单数据
|
||||
*/
|
||||
async function handleLogin(values: Recordable<any>) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(values);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationCodeLogin
|
||||
:form-schema="formSchema"
|
||||
:loading="loading"
|
||||
@submit="handleLogin"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { AuthenticationForgetPassword, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
defineOptions({ name: 'ForgetPassword' });
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: 'example@example.com',
|
||||
},
|
||||
fieldName: 'email',
|
||||
label: $t('authentication.email'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, { message: $t('authentication.emailTip') })
|
||||
.email($t('authentication.emailValidErrorTip')),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function handleSubmit(value: Recordable<any>) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('reset email:', value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationForgetPassword
|
||||
:form-schema="formSchema"
|
||||
:loading="loading"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
57
apps/web-antd/src/views/_core/authentication/login.vue
Normal file
57
apps/web-antd/src/views/_core/authentication/login.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
|
||||
import { computed, markRaw } from 'vue';
|
||||
|
||||
import { AuthenticationLogin, SliderCaptcha, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
defineOptions({ name: 'Login' });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.usernameTip'),
|
||||
},
|
||||
fieldName: 'username',
|
||||
label: $t('authentication.username'),
|
||||
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.password'),
|
||||
},
|
||||
fieldName: 'password',
|
||||
label: $t('authentication.password'),
|
||||
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
|
||||
},
|
||||
{
|
||||
component: markRaw(SliderCaptcha),
|
||||
fieldName: 'captcha',
|
||||
rules: z.boolean().refine((value) => value, {
|
||||
message: $t('authentication.verifyRequiredTip'),
|
||||
}),
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationLogin
|
||||
:form-schema="formSchema"
|
||||
:loading="authStore.loginLoading"
|
||||
:show-third-party-login="false"
|
||||
:show-register="false"
|
||||
:show-forget-password="false"
|
||||
:show-qrcode-login="false"
|
||||
:show-code-login="false"
|
||||
@submit="authStore.authLogin"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { AuthenticationQrCodeLogin } from '@vben/common-ui';
|
||||
import { LOGIN_PATH } from '@vben/constants';
|
||||
|
||||
defineOptions({ name: 'QrCodeLogin' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationQrCodeLogin :login-path="LOGIN_PATH" />
|
||||
</template>
|
||||
96
apps/web-antd/src/views/_core/authentication/register.vue
Normal file
96
apps/web-antd/src/views/_core/authentication/register.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { computed, h, ref } from 'vue';
|
||||
|
||||
import { AuthenticationRegister, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
defineOptions({ name: 'Register' });
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.usernameTip'),
|
||||
},
|
||||
fieldName: 'username',
|
||||
label: $t('authentication.username'),
|
||||
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: $t('authentication.password'),
|
||||
},
|
||||
fieldName: 'password',
|
||||
label: $t('authentication.password'),
|
||||
renderComponentContent() {
|
||||
return {
|
||||
strengthText: () => $t('authentication.passwordStrength'),
|
||||
};
|
||||
},
|
||||
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.confirmPassword'),
|
||||
},
|
||||
dependencies: {
|
||||
rules(values) {
|
||||
const { password } = values;
|
||||
return z
|
||||
.string({ required_error: $t('authentication.passwordTip') })
|
||||
.min(1, { message: $t('authentication.passwordTip') })
|
||||
.refine((value) => value === password, {
|
||||
message: $t('authentication.confirmPasswordTip'),
|
||||
});
|
||||
},
|
||||
triggerFields: ['password'],
|
||||
},
|
||||
fieldName: 'confirmPassword',
|
||||
label: $t('authentication.confirmPassword'),
|
||||
},
|
||||
{
|
||||
component: 'VbenCheckbox',
|
||||
fieldName: 'agreePolicy',
|
||||
renderComponentContent: () => ({
|
||||
default: () =>
|
||||
h('span', [
|
||||
$t('authentication.agree'),
|
||||
h(
|
||||
'a',
|
||||
{
|
||||
class: 'vben-link ml-1 ',
|
||||
href: '',
|
||||
},
|
||||
`${$t('authentication.privacyPolicy')} & ${$t('authentication.terms')}`,
|
||||
),
|
||||
]),
|
||||
}),
|
||||
rules: z.boolean().refine((value) => !!value, {
|
||||
message: $t('authentication.agreeTip'),
|
||||
}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function handleSubmit(value: Recordable<any>) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('register submit:', value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationRegister
|
||||
:form-schema="formSchema"
|
||||
:loading="loading"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
7
apps/web-antd/src/views/_core/fallback/coming-soon.vue
Normal file
7
apps/web-antd/src/views/_core/fallback/coming-soon.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="coming-soon" />
|
||||
</template>
|
||||
9
apps/web-antd/src/views/_core/fallback/forbidden.vue
Normal file
9
apps/web-antd/src/views/_core/fallback/forbidden.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback403Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="403" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback500Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="500" />
|
||||
</template>
|
||||
9
apps/web-antd/src/views/_core/fallback/not-found.vue
Normal file
9
apps/web-antd/src/views/_core/fallback/not-found.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback404Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="404" />
|
||||
</template>
|
||||
9
apps/web-antd/src/views/_core/fallback/offline.vue
Normal file
9
apps/web-antd/src/views/_core/fallback/offline.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'FallbackOfflineDemo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="offline" />
|
||||
</template>
|
||||
101
apps/web-antd/src/views/agent/agent-commission-deduction/data.ts
Normal file
101
apps/web-antd/src/views/agent/agent-commission-deduction/data.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
export function useCommissionDeductionColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
title: 'ID',
|
||||
field: 'id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '代理ID',
|
||||
field: 'agent_id',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '被扣代理ID',
|
||||
field: 'deducted_agent_id',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '抽佣金额',
|
||||
field: 'amount',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }) => `¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '产品名称',
|
||||
field: 'product_name',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '抽佣类型',
|
||||
field: 'type',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }: { cellValue: 'cost' | 'pricing' }) => {
|
||||
const typeMap = {
|
||||
cost: '成本抽佣',
|
||||
pricing: '定价抽佣',
|
||||
};
|
||||
return typeMap[cellValue] || cellValue;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
field: 'status',
|
||||
width: 100,
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
options: [
|
||||
{ value: 0, color: 'warning', label: '待结算' },
|
||||
{ value: 1, color: 'success', label: '已结算' },
|
||||
{ value: 2, color: 'error', label: '已取消' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
field: 'create_time',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useCommissionDeductionFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'product_name',
|
||||
label: '产品名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'type',
|
||||
label: '抽佣类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '成本抽佣', value: 'cost' },
|
||||
{ label: '定价抽佣', value: 'pricing' },
|
||||
],
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '待结算', value: 0 },
|
||||
{ label: '已结算', value: 1 },
|
||||
{ label: '已取消', value: 2 },
|
||||
],
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getAgentCommissionDeductionList } from '#/api/agent';
|
||||
|
||||
import {
|
||||
useCommissionDeductionColumns,
|
||||
useCommissionDeductionFormSchema,
|
||||
} from './data';
|
||||
|
||||
interface Props {
|
||||
agentId?: number;
|
||||
}
|
||||
|
||||
interface QueryParams {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const queryParams = computed(() => ({
|
||||
...(props.agentId ? { agent_id: props.agentId } : {}),
|
||||
}));
|
||||
|
||||
const [Grid] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useCommissionDeductionFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useCommissionDeductionColumns(),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
form,
|
||||
page,
|
||||
}: {
|
||||
form: Record<string, any>;
|
||||
page: QueryParams;
|
||||
}) => {
|
||||
return await getAgentCommissionDeductionList({
|
||||
...queryParams.value,
|
||||
...form,
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
});
|
||||
},
|
||||
},
|
||||
props: {
|
||||
result: 'items',
|
||||
total: 'total',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page :auto-content-height="!agentId">
|
||||
<Grid :table-title="agentId ? '上级抽佣列表' : '所有上级抽佣记录'" />
|
||||
</Page>
|
||||
</template>
|
||||
135
apps/web-antd/src/views/agent/agent-commission/data.ts
Normal file
135
apps/web-antd/src/views/agent/agent-commission/data.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { AgentApi } from '#/api';
|
||||
|
||||
// 佣金记录列表列配置
|
||||
export function useCommissionColumns(
|
||||
onActionClick?: (params: { code: string; row: AgentApi.AgentCommissionListItem }) => void,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'agent_id',
|
||||
title: '代理ID',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'order_id',
|
||||
title: '订单ID',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'amount',
|
||||
title: '佣金金额',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }: { cellValue: number }) =>
|
||||
`¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'product_name',
|
||||
title: '产品名称',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
formatter: ({ cellValue }: { cellValue: number }) => {
|
||||
const statusMap: Record<number, string> = {
|
||||
0: '已结算',
|
||||
1: '冻结中',
|
||||
2: '已退款',
|
||||
};
|
||||
return statusMap[cellValue] || '未知';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
width: 160,
|
||||
sortable: true,
|
||||
sortType: 'string' as const,
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
name: 'CellOperation',
|
||||
attrs: {
|
||||
nameField: 'id',
|
||||
nameTitle: '操作',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
options: [
|
||||
{
|
||||
code: 'freeze',
|
||||
text: '冻结',
|
||||
type: 'warning',
|
||||
disabled: (row: AgentApi.AgentCommissionListItem) =>
|
||||
row?.status !== 0,
|
||||
class: (row: AgentApi.AgentCommissionListItem) =>
|
||||
row?.status !== 0 ? '!text-gray-400 !cursor-not-allowed' : '',
|
||||
tooltip: (row: AgentApi.AgentCommissionListItem) => {
|
||||
if (row?.status === 1) return '该佣金已处于冻结中';
|
||||
if (row?.status === 2) return '已取消的佣金无法操作';
|
||||
return '';
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'unfreeze',
|
||||
text: '解冻',
|
||||
type: 'primary',
|
||||
disabled: (row: AgentApi.AgentCommissionListItem) =>
|
||||
row?.status !== 1,
|
||||
class: (row: AgentApi.AgentCommissionListItem) =>
|
||||
row?.status !== 1 ? '!text-gray-400 !cursor-not-allowed' : '',
|
||||
tooltip: (row: AgentApi.AgentCommissionListItem) => {
|
||||
if (row?.status === 0) return '已结算的佣金无需解冻';
|
||||
if (row?.status === 2) return '已退款的佣金无法操作';
|
||||
return '';
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
title: '操作',
|
||||
width: 300,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 佣金记录搜索表单配置
|
||||
export function useCommissionFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'order_id',
|
||||
label: '订单ID',
|
||||
componentProps: {
|
||||
placeholder: '请输入订单ID',
|
||||
style: { width: '100%' },
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'product_name',
|
||||
label: '产品名称',
|
||||
componentProps: {
|
||||
placeholder: '请输入产品名称(支持模糊搜索)',
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
placeholder: '请选择状态',
|
||||
options: [
|
||||
{ label: '已结算', value: 0 },
|
||||
{ label: '冻结中', value: 1 },
|
||||
{ label: '已退款', value: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
456
apps/web-antd/src/views/agent/agent-commission/list.vue
Normal file
456
apps/web-antd/src/views/agent/agent-commission/list.vue
Normal file
@@ -0,0 +1,456 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onMounted, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { Button, message, Modal, Select, Switch, Tooltip } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
batchUnfreezeAgentCommission,
|
||||
getAgentCommissionList,
|
||||
getAgentList,
|
||||
getAgentWallet,
|
||||
updateAgentCommissionStatus,
|
||||
getSystemConfig,
|
||||
updateSystemConfig,
|
||||
} from '#/api/agent';
|
||||
import type { AgentApi } from '#/api';
|
||||
|
||||
import { useCommissionColumns, useCommissionFormSchema } from './data';
|
||||
|
||||
interface Props {
|
||||
agentId?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// 用于一键解冻筛选的代理商ID
|
||||
const unfreezeAgentId = ref<number | undefined>();
|
||||
|
||||
// 佣金安全防御模式配置
|
||||
const commissionSafeMode = ref<boolean>(false);
|
||||
const safeModeLoading = ref<boolean>(false);
|
||||
|
||||
// 代理商列表(完整列表)
|
||||
const allAgentList = ref<AgentApi.AgentListItem[]>([]);
|
||||
|
||||
// 显示在下拉框中的代理商列表(可能是过滤后的)
|
||||
const agentList = ref<AgentApi.AgentListItem[]>([]);
|
||||
|
||||
const queryParams = computed(() => ({
|
||||
...(props.agentId ? { agent_id: props.agentId } : {}),
|
||||
...(unfreezeAgentId.value ? { agent_id: unfreezeAgentId.value } : {}),
|
||||
}));
|
||||
|
||||
// 加载代理商列表
|
||||
async function loadAgentList() {
|
||||
try {
|
||||
const result = await getAgentList({ page: 1, pageSize: 10000 });
|
||||
allAgentList.value = result.items || [];
|
||||
agentList.value = result.items || [];
|
||||
} catch (error) {
|
||||
console.error('加载代理商列表失败:', error);
|
||||
message.error('加载代理商列表失败,请刷新页面重试');
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索时动态过滤代理商(支持ID和手机号)
|
||||
function onAgentSearch(value: string) {
|
||||
if (!value || value.trim() === '') {
|
||||
// 如果输入为空,显示所有代理商
|
||||
agentList.value = allAgentList.value;
|
||||
return;
|
||||
}
|
||||
|
||||
const searchValue = value.trim();
|
||||
|
||||
// 从完整列表中过滤匹配的代理商
|
||||
const filtered = allAgentList.value.filter(agent => {
|
||||
// 匹配代理ID
|
||||
if (agent.id.toString().includes(searchValue)) {
|
||||
return true;
|
||||
}
|
||||
// 匹配手机号
|
||||
if (agent.mobile && agent.mobile.includes(searchValue)) {
|
||||
return true;
|
||||
}
|
||||
// 匹配姓名(如果存在)
|
||||
if (agent.real_name && agent.real_name.includes(searchValue)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
agentList.value = filtered;
|
||||
}
|
||||
|
||||
// 获取未找到时的提示文案
|
||||
function getNotFoundContent() {
|
||||
if (unfreezeAgentId.value) {
|
||||
return '点击确认使用此ID';
|
||||
}
|
||||
return '暂无代理商';
|
||||
}
|
||||
|
||||
// 页面加载时获取代理商列表和系统配置
|
||||
onMounted(async () => {
|
||||
loadAgentList();
|
||||
await loadSystemConfig();
|
||||
});
|
||||
|
||||
// 加载系统配置
|
||||
async function loadSystemConfig() {
|
||||
try {
|
||||
const config = await getSystemConfig();
|
||||
commissionSafeMode.value = config.commission_safe_mode;
|
||||
} catch (error: any) {
|
||||
console.error('加载系统配置失败:', error);
|
||||
message.error('加载系统配置失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 切换安全防御模式
|
||||
async function onSafeModeChange(checked: boolean | string | number) {
|
||||
const isChecked = Boolean(checked);
|
||||
safeModeLoading.value = true;
|
||||
try {
|
||||
await updateSystemConfig({ commission_safe_mode: isChecked });
|
||||
commissionSafeMode.value = isChecked;
|
||||
message.success(`佣金安全防御模式已${isChecked ? '开启' : '关闭'}`);
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.response?.data?.msg || error?.message || '操作失败,请重试';
|
||||
message.error(errorMsg);
|
||||
// 恢复原状态
|
||||
commissionSafeMode.value = !isChecked;
|
||||
} finally {
|
||||
safeModeLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 操作处理函数
|
||||
function onActionClick({ code, row }: { code: string; row: any }) {
|
||||
switch (code) {
|
||||
case 'freeze':
|
||||
onFreeze(row);
|
||||
break;
|
||||
case 'unfreeze':
|
||||
onUnfreeze(row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 冻结佣金
|
||||
async function onFreeze(row: any) {
|
||||
try {
|
||||
// 先获取代理商钱包信息,检查余额是否充足
|
||||
const hideChecking = message.loading({
|
||||
content: '正在检查余额...',
|
||||
duration: 0,
|
||||
key: 'check_balance',
|
||||
});
|
||||
|
||||
const wallet = await getAgentWallet(row.agent_id);
|
||||
hideChecking();
|
||||
|
||||
// 检查余额是否充足
|
||||
if (wallet.balance < row.amount) {
|
||||
Modal.warning({
|
||||
title: '余额不足',
|
||||
content: `该代理商当前可用余额为 ¥${wallet.balance.toFixed(2)},不足以冻结佣金 ¥${row.amount.toFixed(2)}。\n缺少金额:¥${(row.amount - wallet.balance).toFixed(2)}`,
|
||||
okText: '我知道了',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 余额充足,继续冻结操作
|
||||
Modal.confirm({
|
||||
title: '确认冻结',
|
||||
content: `确定要冻结佣金金额 ¥${row.amount.toFixed(2)} 吗?\n当前可用余额:¥${wallet.balance.toFixed(2)},冻结后可用余额:¥${(wallet.balance - row.amount).toFixed(2)}`,
|
||||
okText: '确认冻结',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await updateAgentCommissionStatus(row.id, 1);
|
||||
message.success('佣金已冻结');
|
||||
onRefresh();
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.response?.data?.msg || error?.message || '操作失败,请重试';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.response?.data?.msg || error?.message || '获取钱包信息失败,请重试';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// 解冻佣金到用户余额
|
||||
async function onUnfreeze(row: any) {
|
||||
try {
|
||||
// 获取代理商钱包信息用于显示
|
||||
const hideChecking = message.loading({
|
||||
content: '正在获取钱包信息...',
|
||||
duration: 0,
|
||||
key: 'get_wallet',
|
||||
});
|
||||
|
||||
const wallet = await getAgentWallet(row.agent_id);
|
||||
hideChecking();
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认解冻',
|
||||
content: `确定要解冻佣金金额 ¥${row.amount.toFixed(2)} 吗?\n解冻后将转入用户钱包余额。\n当前可用余额:¥${wallet.balance.toFixed(2)},解冻后可用余额:¥${(wallet.balance + row.amount).toFixed(2)}`,
|
||||
okText: '确认解冻',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await updateAgentCommissionStatus(row.id, 0);
|
||||
message.success('佣金已解冻并转入用户钱包余额');
|
||||
onRefresh();
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.response?.data?.msg || error?.message || '操作失败,请重试';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.response?.data?.msg || error?.message || '获取钱包信息失败,请重试';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新列表
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
// 选中代理商后刷新表格
|
||||
function onAgentSelect(value: any) {
|
||||
console.log('选中代理商:', value);
|
||||
// 如果是清空选择,value 为 undefined
|
||||
// 如果有值,转换为数字
|
||||
unfreezeAgentId.value = value ? parseInt(String(value), 10) : undefined;
|
||||
// 延迟一点让 computed 更新后再刷新
|
||||
setTimeout(() => {
|
||||
onRefresh();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// 批量解冻佣金
|
||||
async function onBatchUnfreeze() {
|
||||
const targetAgentId = unfreezeAgentId.value || props.agentId;
|
||||
|
||||
const hideChecking = message.loading({
|
||||
content: '正在检查数据,请稍候...',
|
||||
duration: 0,
|
||||
key: 'check_frozen_data',
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 查询所有冻结中的佣金记录
|
||||
const frozenCommissions = await getAgentCommissionList({
|
||||
page: 1,
|
||||
pageSize: 10000,
|
||||
status: 1,
|
||||
...(targetAgentId ? { agent_id: targetAgentId } : {}),
|
||||
});
|
||||
|
||||
hideChecking();
|
||||
|
||||
// 如果没有冻结的佣金,直接返回
|
||||
if (!frozenCommissions.items || frozenCommissions.items.length === 0) {
|
||||
message.info({
|
||||
content: '没有需要解冻的冻结佣金',
|
||||
key: 'check_frozen_data',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 统计每个代理商的佣金冻结金额
|
||||
const commissionFrozenMap = new Map<number, number>();
|
||||
frozenCommissions.items.forEach((item: any) => {
|
||||
const currentAmount = commissionFrozenMap.get(item.agent_id) || 0;
|
||||
commissionFrozenMap.set(item.agent_id, currentAmount + item.amount);
|
||||
});
|
||||
|
||||
// 3. 检查每个代理商的钱包冻结余额是否足够
|
||||
let insufficientAgents: string[] = [];
|
||||
let totalFrozenAmount = 0;
|
||||
|
||||
for (const [agentId, commissionAmount] of commissionFrozenMap) {
|
||||
totalFrozenAmount += commissionAmount;
|
||||
|
||||
// 获取该代理商的钱包信息
|
||||
const wallet = await getAgentWallet(agentId);
|
||||
|
||||
if (wallet.frozen_balance < commissionAmount) {
|
||||
// 找到该代理商的信息
|
||||
const agent = allAgentList.value.find(a => a.id === agentId);
|
||||
const agentInfo = agent ? `${agent.real_name || agent.mobile} (ID: ${agentId})` : `ID: ${agentId}`;
|
||||
insufficientAgents.push(
|
||||
`${agentInfo}:钱包冻结余额 ¥${wallet.frozen_balance.toFixed(2)},需要解冻 ¥${commissionAmount.toFixed(2)},缺少 ¥${(commissionAmount - wallet.frozen_balance).toFixed(2)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有余额不足的代理商,显示错误信息
|
||||
if (insufficientAgents.length > 0) {
|
||||
Modal.error({
|
||||
title: '冻结余额不足,无法解冻',
|
||||
width: 600,
|
||||
content: h('div', [
|
||||
h('p', '以下代理商的钱包冻结余额不足以解冻其冻结的佣金:'),
|
||||
h('ul', { style: { 'max-height': '300px', 'overflow-y': 'auto', 'padding-left': '20px' } },
|
||||
insufficientAgents.map(msg => h('li', { style: { marginBottom: '8px' } }, msg))
|
||||
),
|
||||
h('p', { style: { marginTop: '16px', color: '#999' } }, '请先核实钱包冻结余额数据,或联系技术支持。'),
|
||||
]),
|
||||
okText: '我知道了',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 余额检查通过,确认解冻
|
||||
const content = targetAgentId
|
||||
? `确定要一键解冻代理商 ID: ${targetAgentId} 所有冻结中的佣金吗?\n\n共 ${frozenCommissions.items.length} 条记录,总金额 ¥${totalFrozenAmount.toFixed(2)}。\n解冻后将全部转入用户钱包余额。`
|
||||
: `确定要一键解冻所有冻结中的佣金吗?\n\n共 ${frozenCommissions.items.length} 条记录,总金额 ¥${totalFrozenAmount.toFixed(2)}。\n解冻后将全部转入用户钱包余额。`;
|
||||
|
||||
Modal.confirm({
|
||||
title: '批量解冻确认',
|
||||
content,
|
||||
okText: '确认解冻',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const hideLoading = message.loading({
|
||||
content: '正在批量解冻佣金,请稍候...',
|
||||
duration: 0,
|
||||
key: 'batch_unfreeze',
|
||||
});
|
||||
try {
|
||||
const result = await batchUnfreezeAgentCommission(targetAgentId);
|
||||
message.success({
|
||||
content: `批量解冻成功!共解冻 ${result.count} 条记录,总金额 ¥${result.amount.toFixed(2)}`,
|
||||
key: 'batch_unfreeze',
|
||||
});
|
||||
onRefresh();
|
||||
} catch (error: any) {
|
||||
hideLoading();
|
||||
const errorMsg = error?.response?.data?.msg || error?.message || '批量解冻失败,请重试';
|
||||
// 如果是版本冲突错误,给出更友好的提示
|
||||
if (errorMsg.includes('update db no rows change') || errorMsg.includes('版本冲突') || errorMsg.includes('状态已被其他操作修改')) {
|
||||
message.error({
|
||||
content: '批量解冻失败:部分佣金或钱包数据已被其他操作修改,请稍后重试。如果问题持续,请联系管理员。',
|
||||
key: 'batch_unfreeze',
|
||||
});
|
||||
} else {
|
||||
message.error({
|
||||
content: `批量解冻失败:${errorMsg}`,
|
||||
key: 'batch_unfreeze',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
hideChecking();
|
||||
const errorMsg = error?.response?.data?.msg || error?.message || '数据检查失败,请重试';
|
||||
message.error({
|
||||
content: `检查失败:${errorMsg}`,
|
||||
key: 'check_frozen_data',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useCommissionFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useCommissionColumns(onActionClick),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page, form, sort }: any, formValues: Record<string, any>) => {
|
||||
return await getAgentCommissionList({
|
||||
...queryParams.value,
|
||||
...formValues,
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
});
|
||||
},
|
||||
},
|
||||
props: {
|
||||
result: 'items',
|
||||
total: 'total',
|
||||
},
|
||||
autoLoad: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page :auto-content-height="!agentId">
|
||||
<Grid :table-title="agentId ? '佣金记录列表' : '所有佣金记录'">
|
||||
<template #toolbar-tools>
|
||||
<div class="flex items-center">
|
||||
<Tooltip placement="top" title="开启后,佣金结算时将先冻结到钱包冻结余额,需要手动解冻才能使用;关闭后,佣金将直接结算到可用余额">
|
||||
<div class="flex items-center gap-2 mr-4">
|
||||
<span class="text-sm text-gray-600">安全防御机制:</span>
|
||||
<Switch
|
||||
v-model:checked="commissionSafeMode"
|
||||
:loading="safeModeLoading"
|
||||
checked-children="开启"
|
||||
un-checked-children="关闭"
|
||||
@change="onSafeModeChange"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div class="w-px h-6 bg-gray-300 mx-2"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-600">选择代理商:</span>
|
||||
<Select
|
||||
v-model:value="unfreezeAgentId"
|
||||
placeholder="全部代理商 / 输入代理ID或手机号"
|
||||
:allow-clear="true"
|
||||
:loading="agentList.length === 0"
|
||||
style="width: 260px"
|
||||
show-search
|
||||
:filter-option="false"
|
||||
:show-arrow="true"
|
||||
:not-found-content="getNotFoundContent()"
|
||||
@search="onAgentSearch"
|
||||
@change="onAgentSelect"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="agent in agentList"
|
||||
:key="agent.id"
|
||||
:value="agent.id"
|
||||
:label="`${agent.real_name || agent.mobile} (ID: ${agent.id})`"
|
||||
>
|
||||
{{ agent.real_name || agent.mobile }} (ID: {{ agent.id }})
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Button type="primary" @click="onBatchUnfreeze">
|
||||
<span class="mr-1">⚡</span>
|
||||
一键解冻
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
51
apps/web-antd/src/views/agent/agent-links/data.ts
Normal file
51
apps/web-antd/src/views/agent/agent-links/data.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
// 推广链接列表列配置
|
||||
export function useLinkColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'agent_id',
|
||||
title: '代理ID',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'product_name',
|
||||
title: '产品名称',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
field: 'price',
|
||||
title: '价格',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }) => `¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'link_identifier',
|
||||
title: '推广码',
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
width: 160,
|
||||
sortable: true,
|
||||
sortType: 'string' as const,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 推广链接搜索表单配置
|
||||
export function useLinkFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'product_name',
|
||||
label: '产品名称',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'link_identifier',
|
||||
label: '推广码',
|
||||
},
|
||||
];
|
||||
}
|
||||
64
apps/web-antd/src/views/agent/agent-links/list.vue
Normal file
64
apps/web-antd/src/views/agent/agent-links/list.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getAgentLinkList } from '#/api/agent';
|
||||
|
||||
import { useLinkColumns, useLinkFormSchema } from './data';
|
||||
|
||||
interface Props {
|
||||
agentId?: number;
|
||||
}
|
||||
|
||||
interface QueryParams {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const queryParams = computed(() => ({
|
||||
...(props.agentId ? { agent_id: props.agentId } : {}),
|
||||
}));
|
||||
|
||||
const [Grid] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useLinkFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useLinkColumns(),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
form,
|
||||
}: {
|
||||
form: Record<string, any>;
|
||||
page: QueryParams;
|
||||
}) => {
|
||||
return await getAgentLinkList({
|
||||
...queryParams.value,
|
||||
...form,
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
});
|
||||
},
|
||||
},
|
||||
props: {
|
||||
result: 'items',
|
||||
total: 'total',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page :auto-content-height="!agentId">
|
||||
<Grid :table-title="agentId ? '推广链接列表' : '所有推广链接'" />
|
||||
</Page>
|
||||
</template>
|
||||
436
apps/web-antd/src/views/agent/agent-list/data.ts
Normal file
436
apps/web-antd/src/views/agent/agent-list/data.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
// 表单配置
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'mobile',
|
||||
label: '手机号',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'level_name',
|
||||
label: '等级名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'region',
|
||||
label: '区域',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'DatePicker',
|
||||
fieldName: 'membership_expiry_time',
|
||||
label: '会员到期时间',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
showTime: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 搜索表单配置
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'mobile',
|
||||
label: '手机号',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'region',
|
||||
label: '区域',
|
||||
},
|
||||
{
|
||||
component: 'RangePicker',
|
||||
fieldName: 'create_time',
|
||||
label: '创建时间',
|
||||
componentProps: {
|
||||
showTime: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
// 表格列配置
|
||||
export function useColumns(): VxeTableGridOptions['columns'] {
|
||||
const columns = [
|
||||
{
|
||||
field: 'id',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'user_id',
|
||||
title: '用户ID',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'level_name',
|
||||
title: '等级名称',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }: { cellValue: string }) => {
|
||||
if (cellValue === '' || cellValue === 'normal') {
|
||||
return '普通代理';
|
||||
}
|
||||
return cellValue;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'region',
|
||||
title: '区域',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
field: 'mobile',
|
||||
title: '手机号',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
options: [
|
||||
{ value: 'approved', color: 'success', label: '已认证' },
|
||||
{ value: 'pending', color: 'warning', label: '审核中' },
|
||||
{ value: 'rejected', color: 'error', label: '已拒绝' },
|
||||
{ value: '', color: 'default', label: '未认证' },
|
||||
],
|
||||
},
|
||||
field: 'real_name_status',
|
||||
title: '实名认证状态',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
field: 'real_name',
|
||||
title: '实名姓名',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }: { cellValue: string }) => {
|
||||
if (!cellValue) return '-';
|
||||
return cellValue;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'id_card',
|
||||
title: '身份证号',
|
||||
width: 180,
|
||||
formatter: ({ cellValue }: { cellValue: string }) => {
|
||||
if (!cellValue) return '-';
|
||||
// 只显示前6位和后4位,中间用*代替
|
||||
return `${cellValue.slice(0, 6)}${'*'.repeat(8)}${cellValue.slice(-4)}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'membership_expiry_time',
|
||||
title: '会员到期时间',
|
||||
width: 160,
|
||||
sortable: true,
|
||||
sortType: 'string' as const,
|
||||
},
|
||||
{
|
||||
field: 'balance',
|
||||
title: '钱包余额',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }: { cellValue: number }) =>
|
||||
`¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'total_earnings',
|
||||
title: '累计收益',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }: { cellValue: number }) =>
|
||||
`¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'frozen_balance',
|
||||
title: '冻结余额',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }: { cellValue: number }) =>
|
||||
`¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'withdrawn_amount',
|
||||
title: '提现总额',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }: { cellValue: number }) =>
|
||||
`¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '成为代理时间',
|
||||
width: 160,
|
||||
sortable: true,
|
||||
sortType: 'string' as const,
|
||||
},
|
||||
{
|
||||
align: 'center' as const,
|
||||
slots: { default: 'operation' },
|
||||
field: 'operation',
|
||||
fixed: 'right' as const,
|
||||
title: '操作',
|
||||
width: 280,
|
||||
},
|
||||
];
|
||||
return columns;
|
||||
}
|
||||
// 推广链接列表列配置
|
||||
export function useLinkColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'product_name',
|
||||
title: '产品名称',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
field: 'price',
|
||||
title: '价格',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }) => `¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'link_identifier',
|
||||
title: '推广码',
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
width: 160,
|
||||
sortable: true,
|
||||
sortType: 'string' as const,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 推广链接搜索表单配置
|
||||
export function useLinkFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'product_name',
|
||||
label: '产品名称',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'link_identifier',
|
||||
label: '推广码',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 佣金记录列表列配置
|
||||
export function useCommissionColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'id',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'agent_id',
|
||||
title: '代理ID',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'order_id',
|
||||
title: '订单ID',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'amount',
|
||||
title: '佣金金额',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }: { cellValue: number }) =>
|
||||
`¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'product_name',
|
||||
title: '产品名称',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
formatter: ({ cellValue }: { cellValue: number }) => {
|
||||
const statusMap: Record<number, string> = {
|
||||
0: '待结算',
|
||||
1: '已结算',
|
||||
2: '已取消',
|
||||
};
|
||||
return statusMap[cellValue] || '未知';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
width: 160,
|
||||
sortable: true,
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
|
||||
// 佣金记录搜索表单配置
|
||||
export function useCommissionFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'product_name',
|
||||
label: '产品名称',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: '待结算', value: 0 },
|
||||
{ label: '已结算', value: 1 },
|
||||
{ label: '已取消', value: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 奖励记录列表列配置
|
||||
export function useRewardColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'id',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'agent_id',
|
||||
title: '代理ID',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'relation_agent_id',
|
||||
title: '关联代理ID',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'amount',
|
||||
title: '奖励金额',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }: { cellValue: number }) =>
|
||||
`¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
title: '奖励类型',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
width: 160,
|
||||
sortable: true,
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
|
||||
// 奖励记录搜索表单配置
|
||||
export function useRewardFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'type',
|
||||
label: '奖励类型',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'relation_agent_id',
|
||||
label: '关联代理ID',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 提现记录列表列配置
|
||||
export function useWithdrawalColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'id',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'agent_id',
|
||||
title: '代理ID',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'withdraw_no',
|
||||
title: '提现单号',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
field: 'amount',
|
||||
title: '提现金额',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }: { cellValue: number }) =>
|
||||
`¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
formatter: ({ cellValue }: { cellValue: number }) => {
|
||||
const statusMap: Record<number, string> = {
|
||||
0: '待审核',
|
||||
1: '已通过',
|
||||
2: '已拒绝',
|
||||
3: '已打款',
|
||||
};
|
||||
return statusMap[cellValue] || '未知';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'payee_account',
|
||||
title: '收款账户',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
field: 'remark',
|
||||
title: '备注',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
width: 160,
|
||||
sortable: true,
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
|
||||
// 提现记录搜索表单配置
|
||||
export function useWithdrawalFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'withdraw_no',
|
||||
label: '提现单号',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: '待审核', value: 0 },
|
||||
{ label: '已通过', value: 1 },
|
||||
{ label: '已拒绝', value: 2 },
|
||||
{ label: '已打款', value: 3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
405
apps/web-antd/src/views/agent/agent-list/list.vue
Normal file
405
apps/web-antd/src/views/agent/agent-list/list.vue
Normal file
@@ -0,0 +1,405 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeGridListeners,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { AgentApi } from '#/api/agent';
|
||||
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Card, Dropdown, Menu } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getAgentList } from '#/api/agent';
|
||||
|
||||
import { useColumns, useGridFormSchema } from './data';
|
||||
import CommissionDeductionModal from './modules/commission-deduction-modal.vue';
|
||||
import CommissionModal from './modules/commission-modal.vue';
|
||||
import CommissionHistoryModal from './modules/commission-history-modal.vue';
|
||||
import Form from './modules/form.vue';
|
||||
import LinkModal from './modules/link-modal.vue';
|
||||
import PlatformDeductionModal from './modules/platform-deduction-modal.vue';
|
||||
import RewardModal from './modules/reward-modal.vue';
|
||||
import WithdrawalModal from './modules/withdrawal-modal.vue';
|
||||
import BalanceModal from './modules/balance-modal.vue';
|
||||
import WalletTransactionModal from './modules/wallet-transaction-modal.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// 表单抽屉
|
||||
const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 推广链接弹窗
|
||||
const [LinkModalComponent, linkModalApi] = useVbenModal({
|
||||
connectedComponent: LinkModal,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 佣金记录弹窗
|
||||
const [CommissionModalComponent, commissionModalApi] = useVbenModal({
|
||||
connectedComponent: CommissionModal,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 奖励记录弹窗
|
||||
const [RewardModalComponent, rewardModalApi] = useVbenModal({
|
||||
connectedComponent: RewardModal,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 提现记录弹窗
|
||||
const [WithdrawalModalComponent, withdrawalModalApi] = useVbenModal({
|
||||
connectedComponent: WithdrawalModal,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 上级抽佣弹窗
|
||||
const [CommissionDeductionModalComponent, commissionDeductionModalApi] =
|
||||
useVbenModal({
|
||||
connectedComponent: CommissionDeductionModal,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 平台抽佣弹窗
|
||||
const [PlatformDeductionModalComponent, platformDeductionModalApi] =
|
||||
useVbenModal({
|
||||
connectedComponent: PlatformDeductionModal,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 修改余额弹窗
|
||||
const [BalanceModalComponent, balanceModalApi] = useVbenModal({
|
||||
connectedComponent: BalanceModal,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 钱包流水记录弹窗
|
||||
const [WalletTransactionModalComponent, walletTransactionModalApi] = useVbenModal({
|
||||
connectedComponent: WalletTransactionModal,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 历史佣金记录弹窗
|
||||
const [CommissionHistoryModalComponent, commissionHistoryModalApi] = useVbenModal({
|
||||
connectedComponent: CommissionHistoryModal,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 表格配置
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
fieldMappingTime: [
|
||||
['create_time', ['create_time_start', 'create_time_end']],
|
||||
],
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridEvents: {
|
||||
sortChange: () => {
|
||||
gridApi.query();
|
||||
},
|
||||
} as VxeGridListeners<AgentApi.AgentListItem>,
|
||||
gridOptions: {
|
||||
columns: useColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
sortConfig: {
|
||||
remote: true,
|
||||
multiple: false,
|
||||
trigger: 'default',
|
||||
orders: ['asc', 'desc', null],
|
||||
resetPage: true,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page, sort }, formValues) => {
|
||||
const sortParams = sort
|
||||
? {
|
||||
order_by: sort.field,
|
||||
order_type: sort.order,
|
||||
}
|
||||
: {};
|
||||
|
||||
const res = await getAgentList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
...sortParams,
|
||||
parent_agent_id: route.query.parent_agent_id
|
||||
? Number(route.query.parent_agent_id)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
...res,
|
||||
sort: sort || null,
|
||||
};
|
||||
},
|
||||
},
|
||||
props: {
|
||||
result: 'items',
|
||||
total: 'total',
|
||||
},
|
||||
autoLoad: true,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
} as VxeTableGridOptions<AgentApi.AgentListItem>,
|
||||
});
|
||||
|
||||
// 更多操作菜单项
|
||||
const moreMenuItems = [
|
||||
{
|
||||
key: 'update-balance',
|
||||
label: '修改余额',
|
||||
},
|
||||
{
|
||||
key: 'commission-history',
|
||||
label: '历史佣金记录',
|
||||
},
|
||||
{
|
||||
key: 'wallet-transaction',
|
||||
label: '钱包流水记录',
|
||||
},
|
||||
{
|
||||
key: 'links',
|
||||
label: '推广链接',
|
||||
},
|
||||
|
||||
// {
|
||||
// key: 'commission',
|
||||
// label: '佣金记录',
|
||||
// },
|
||||
// {
|
||||
// key: 'commission-deduction',
|
||||
// label: '上级抽佣',
|
||||
// },
|
||||
// {
|
||||
// key: 'platform-deduction',
|
||||
// label: '平台抽佣',
|
||||
// },
|
||||
{
|
||||
key: 'reward',
|
||||
label: '奖励记录',
|
||||
},
|
||||
{
|
||||
key: 'withdrawal',
|
||||
label: '提现记录',
|
||||
},
|
||||
];
|
||||
|
||||
// 上级代理信息
|
||||
const parentAgentId = computed(() => route.query.parent_agent_id);
|
||||
|
||||
// 返回上级列表
|
||||
function onBackToParent() {
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
parent_agent_id: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 操作处理函数
|
||||
function onActionClick(
|
||||
e:
|
||||
| OnActionClickParams<AgentApi.AgentListItem>
|
||||
| { code: string; row: AgentApi.AgentListItem },
|
||||
) {
|
||||
switch (e.code) {
|
||||
case 'commission': {
|
||||
onViewCommission(e.row);
|
||||
break;
|
||||
}
|
||||
case 'commission-history': {
|
||||
onViewCommissionHistory(e.row);
|
||||
break;
|
||||
}
|
||||
case 'commission-deduction': {
|
||||
onViewCommissionDeduction(e.row);
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
onEdit(e.row);
|
||||
break;
|
||||
}
|
||||
case 'links': {
|
||||
onViewLinks(e.row);
|
||||
break;
|
||||
}
|
||||
case 'platform-deduction': {
|
||||
onViewPlatformDeduction(e.row);
|
||||
break;
|
||||
}
|
||||
case 'reward': {
|
||||
onViewReward(e.row);
|
||||
break;
|
||||
}
|
||||
case 'update-balance': {
|
||||
onUpdateBalance(e.row);
|
||||
break;
|
||||
}
|
||||
case 'view-sub-agent': {
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
parent_agent_id: e.row.id,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'withdrawal': {
|
||||
onViewWithdrawal(e.row);
|
||||
break;
|
||||
}
|
||||
case 'wallet-transaction': {
|
||||
onViewWalletTransaction(e.row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑处理
|
||||
function onEdit(row: AgentApi.AgentListItem) {
|
||||
formDrawerApi.setData(row).open();
|
||||
}
|
||||
|
||||
// 查看推广链接
|
||||
function onViewLinks(row: AgentApi.AgentListItem) {
|
||||
linkModalApi.setData({ agentId: row.id }).open();
|
||||
}
|
||||
|
||||
// 修改余额
|
||||
function onUpdateBalance(row: AgentApi.AgentListItem) {
|
||||
balanceModalApi.setData({ agentId: row.id }).open();
|
||||
}
|
||||
|
||||
// 查看钱包流水记录
|
||||
function onViewWalletTransaction(row: AgentApi.AgentListItem) {
|
||||
walletTransactionModalApi.setData({ agentId: row.id }).open();
|
||||
}
|
||||
|
||||
// 查看佣金记录
|
||||
function onViewCommission(row: AgentApi.AgentListItem) {
|
||||
commissionModalApi.setData({ agentId: row.id }).open();
|
||||
}
|
||||
|
||||
// 查看奖励记录
|
||||
function onViewReward(row: AgentApi.AgentListItem) {
|
||||
rewardModalApi.setData({ agentId: row.id }).open();
|
||||
}
|
||||
|
||||
// 查看提现记录
|
||||
function onViewWithdrawal(row: AgentApi.AgentListItem) {
|
||||
withdrawalModalApi.setData({ agentId: row.id }).open();
|
||||
}
|
||||
|
||||
// 查看上级抽佣记录
|
||||
function onViewCommissionDeduction(row: AgentApi.AgentListItem) {
|
||||
commissionDeductionModalApi.setData({ agentId: row.id }).open();
|
||||
}
|
||||
|
||||
// 查看平台抽佣记录
|
||||
function onViewPlatformDeduction(row: AgentApi.AgentListItem) {
|
||||
platformDeductionModalApi.setData({ agentId: row.id }).open();
|
||||
}
|
||||
|
||||
// 查看历史佣金记录
|
||||
function onViewCommissionHistory(row: AgentApi.AgentListItem) {
|
||||
commissionHistoryModalApi.setData({ agentId: row.id }).open();
|
||||
}
|
||||
|
||||
// 刷新处理
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormDrawer @success="onRefresh" />
|
||||
<LinkModalComponent />
|
||||
<CommissionModalComponent />
|
||||
<CommissionHistoryModalComponent />
|
||||
<CommissionDeductionModalComponent />
|
||||
<PlatformDeductionModalComponent />
|
||||
<RewardModalComponent />
|
||||
<WithdrawalModalComponent />
|
||||
<BalanceModalComponent @success="onRefresh" />
|
||||
<WalletTransactionModalComponent />
|
||||
|
||||
<!-- 上级代理信息卡片 -->
|
||||
<Card v-if="parentAgentId" class="mb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<Button @click="onBackToParent">返回上级列表</Button>
|
||||
<div>上级代理ID:{{ parentAgentId }}</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Grid table-title="代理列表">
|
||||
<template #operation="{ row }">
|
||||
<div class="operation-buttons">
|
||||
<Button type="link" @click="onActionClick({ code: 'edit', row })">
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
@click="onActionClick({ code: 'view-sub-agent', row })"
|
||||
>
|
||||
查看下级
|
||||
</Button>
|
||||
<!-- <Button
|
||||
type="link"
|
||||
@click="onActionClick({ code: 'order-record', row })"
|
||||
>
|
||||
订单记录
|
||||
</Button> -->
|
||||
<Dropdown>
|
||||
<Button type="link">更多操作</Button>
|
||||
<template #overlay>
|
||||
<Menu
|
||||
:items="moreMenuItems"
|
||||
@click="(e) => onActionClick({ code: String(e.key), row })"
|
||||
/>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.parent-agent-card {
|
||||
margin-bottom: 16px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.operation-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
gap: 4px;
|
||||
|
||||
:deep(.ant-btn-link) {
|
||||
padding: 0 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,161 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { getAgentWallet, updateAgentWalletBalance } from '#/api/agent';
|
||||
import type { AgentApi } from '#/api/agent';
|
||||
|
||||
interface ModalData {
|
||||
agentId: number;
|
||||
}
|
||||
|
||||
// 钱包信息
|
||||
const walletInfo = ref<AgentApi.AgentWalletInfo>({
|
||||
balance: 0,
|
||||
frozen_balance: 0,
|
||||
total_earnings: 0,
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'success'): void;
|
||||
}>();
|
||||
|
||||
const [ModalComponent, modalApi] = useVbenModal({
|
||||
title: '修改余额',
|
||||
destroyOnClose: true,
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
fetchWalletInfo();
|
||||
// 重置表单
|
||||
formApi.setValues({
|
||||
operation_type: 'add',
|
||||
amount: undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const modalData = computed(() => modalApi.getData<ModalData>());
|
||||
|
||||
// 获取钱包信息
|
||||
async function fetchWalletInfo() {
|
||||
const agentId = modalData.value?.agentId;
|
||||
if (!agentId) return;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
const data = await getAgentWallet(agentId);
|
||||
walletInfo.value = data;
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取钱包信息失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 表单引用
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: [
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
fieldName: 'operation_type',
|
||||
label: '操作类型',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '增加余额', value: 'add' },
|
||||
{ label: '减少余额', value: 'subtract' },
|
||||
],
|
||||
},
|
||||
rules: 'required',
|
||||
defaultValue: 'add',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'amount',
|
||||
label: '金额',
|
||||
componentProps: {
|
||||
precision: 2,
|
||||
min: 0.01,
|
||||
placeholder: '请输入金额',
|
||||
style: { width: '100%' },
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
// 提交前确认
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
|
||||
const values = await formApi.getValues();
|
||||
if (!values) return;
|
||||
|
||||
// 根据操作类型确定金额正负
|
||||
const finalAmount = values.operation_type === 'subtract' ? -Math.abs(values.amount) : Math.abs(values.amount);
|
||||
|
||||
// 二次确认
|
||||
Modal.confirm({
|
||||
title: '确认修改余额',
|
||||
content: `您确定要${values.operation_type === 'add' ? '增加' : '减少'} ¥${Math.abs(values.amount).toFixed(2)} 的余额吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await updateAgentWalletBalance({
|
||||
agent_id: modalData.value?.agentId || 0,
|
||||
amount: finalAmount,
|
||||
});
|
||||
|
||||
message.success('修改余额成功');
|
||||
modalApi.close();
|
||||
emit('success');
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '修改余额失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// 表单验证失败,不处理
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
function handleReset() {
|
||||
formApi.setValues({
|
||||
operation_type: 'add',
|
||||
amount: undefined,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalComponent width="500px" :footer="false">
|
||||
<div class="balance-modal">
|
||||
<Form />
|
||||
<div class="form-actions">
|
||||
<a-button @click="handleReset">重置</a-button>
|
||||
<a-button type="primary" @click="handleSubmit">确认修改</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalComponent>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.balance-modal {
|
||||
padding: 16px 0;
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import CommissionDeductionList from '../../agent-commission-deduction/list.vue';
|
||||
|
||||
interface ModalData {
|
||||
agentId: number;
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '上级抽佣记录',
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const modalData = computed(() => modalApi.getData<ModalData>());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[calc(100vw-200px)]" :footer="false">
|
||||
<div class="agent-commission-deduction-modal">
|
||||
<CommissionDeductionList :agent-id="modalData?.agentId" />
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.agent-commission-deduction-modal {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,323 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { Card, Statistic, Row, Col, DatePicker, Button, Space } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getAgentCommissionList } from '#/api/agent';
|
||||
import type { AgentApi } from '#/api';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
|
||||
interface ModalData {
|
||||
agentId: number;
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '历史佣金记录',
|
||||
destroyOnClose: true,
|
||||
footer: false,
|
||||
});
|
||||
|
||||
const modalData = computed(() => modalApi.getData<ModalData>());
|
||||
|
||||
// 时间范围选择
|
||||
const dateRange = ref<[Dayjs, Dayjs] | undefined>(undefined);
|
||||
|
||||
// 统计数据
|
||||
const statistics = ref({
|
||||
totalAmount: 0,
|
||||
settledAmount: 0,
|
||||
frozenAmount: 0,
|
||||
refundedAmount: 0,
|
||||
totalCount: 0,
|
||||
settledCount: 0,
|
||||
frozenCount: 0,
|
||||
refundedCount: 0,
|
||||
});
|
||||
|
||||
// 快捷时间范围选择
|
||||
function selectTimeRange(range: string) {
|
||||
const now = dayjs();
|
||||
let startDate: Dayjs;
|
||||
|
||||
switch (range) {
|
||||
case '7d':
|
||||
startDate = now.subtract(7, 'day');
|
||||
break;
|
||||
case '1m':
|
||||
startDate = now.subtract(1, 'month');
|
||||
break;
|
||||
case '3m':
|
||||
startDate = now.subtract(3, 'month');
|
||||
break;
|
||||
case '1y':
|
||||
startDate = now.subtract(1, 'year');
|
||||
break;
|
||||
default:
|
||||
dateRange.value = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
dateRange.value = [startDate, now];
|
||||
}
|
||||
|
||||
// 重置时间范围
|
||||
function resetTimeRange() {
|
||||
dateRange.value = undefined;
|
||||
}
|
||||
|
||||
// 表格配置
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
fieldMappingTime: [['create_time', ['create_time_start', 'create_time_end']]],
|
||||
schema: [
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'order_id',
|
||||
label: '订单ID',
|
||||
componentProps: {
|
||||
placeholder: '请输入订单ID',
|
||||
style: { width: '100%' },
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'product_name',
|
||||
label: '产品名称',
|
||||
componentProps: {
|
||||
placeholder: '请输入产品名称',
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
placeholder: '请选择状态',
|
||||
options: [
|
||||
{ label: '已结算', value: 0 },
|
||||
{ label: '冻结中', value: 1 },
|
||||
{ label: '已退款', value: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: [
|
||||
{
|
||||
field: 'id',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'order_id',
|
||||
title: '订单ID',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'amount',
|
||||
title: '佣金金额',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }: { cellValue: number }) =>
|
||||
`¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'product_name',
|
||||
title: '产品名称',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
formatter: ({ cellValue }: { cellValue: number }) => {
|
||||
const statusMap: Record<number, string> = {
|
||||
0: '已结算',
|
||||
1: '冻结中',
|
||||
2: '已退款',
|
||||
};
|
||||
return statusMap[cellValue] || '未知';
|
||||
},
|
||||
className: ({ cellValue }: { cellValue: number }) => {
|
||||
if (cellValue === 0) return 'text-green-600';
|
||||
if (cellValue === 1) return 'text-orange-600';
|
||||
if (cellValue === 2) return 'text-red-600';
|
||||
return '';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
width: 160,
|
||||
sortable: true,
|
||||
},
|
||||
],
|
||||
height: 600,
|
||||
maxHeight: 800,
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: true,
|
||||
pageSize: 20,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
layouts: ['Total', 'Sizes', 'PrevJump', 'Number', 'NextJump', 'FullJump'],
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }: { page: { currentPage: number; pageSize: number } }, formValues: Record<string, any>) => {
|
||||
const params: any = {
|
||||
agent_id: modalData.value?.agentId,
|
||||
...formValues,
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
};
|
||||
|
||||
// 添加时间范围参数
|
||||
if (dateRange.value && dateRange.value[0] && dateRange.value[1]) {
|
||||
params.create_time_start = dateRange.value[0].format('YYYY-MM-DD HH:mm:ss');
|
||||
params.create_time_end = dateRange.value[1].format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
const result = await getAgentCommissionList(params);
|
||||
|
||||
// 更新统计数据
|
||||
updateStatistics(result.items || []);
|
||||
|
||||
return result;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
result: 'items',
|
||||
total: 'total',
|
||||
},
|
||||
autoLoad: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 更新统计数据
|
||||
function updateStatistics(items: AgentApi.AgentCommissionListItem[]) {
|
||||
statistics.value = {
|
||||
totalCount: items.length,
|
||||
totalAmount: items.reduce((sum, item) => sum + item.amount, 0),
|
||||
settledCount: items.filter(item => item.status === 0).length,
|
||||
settledAmount: items.filter(item => item.status === 0).reduce((sum, item) => sum + item.amount, 0),
|
||||
frozenCount: items.filter(item => item.status === 1).length,
|
||||
frozenAmount: items.filter(item => item.status === 1).reduce((sum, item) => sum + item.amount, 0),
|
||||
refundedCount: items.filter(item => item.status === 2).length,
|
||||
refundedAmount: items.filter(item => item.status === 2).reduce((sum, item) => sum + item.amount, 0),
|
||||
};
|
||||
}
|
||||
|
||||
// 监听时间范围变化,自动刷新表格
|
||||
watch(dateRange, () => {
|
||||
if (gridApi) {
|
||||
gridApi.query();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[calc(100vw-200px)]">
|
||||
<div class="commission-history-modal">
|
||||
<!-- 时间范围选择 -->
|
||||
<Card class="mb-4" title="时间范围选择">
|
||||
<Space :size="12">
|
||||
<span class="text-sm text-gray-600">快速选择:</span>
|
||||
<Button size="small" @click="selectTimeRange('7d')">近7日</Button>
|
||||
<Button size="small" @click="selectTimeRange('1m')">近1月</Button>
|
||||
<Button size="small" @click="selectTimeRange('3m')">近3月</Button>
|
||||
<Button size="small" @click="selectTimeRange('1y')">近1年</Button>
|
||||
<Button size="small" type="default" @click="resetTimeRange">全部</Button>
|
||||
<span class="text-sm text-gray-400 ml-4">|</span>
|
||||
<span class="text-sm text-gray-600">自定义:</span>
|
||||
<DatePicker.RangePicker
|
||||
v-model:value="dateRange"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
:placeholder="['开始时间', '结束时间']"
|
||||
style="width: 380px"
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<Card class="mb-4" title="统计信息">
|
||||
<Row :gutter="[16, 16]">
|
||||
<Col :span="6">
|
||||
<Statistic title="总记录数" :value="statistics.totalCount" />
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Statistic
|
||||
title="总佣金金额"
|
||||
:value="statistics.totalAmount"
|
||||
:precision="2"
|
||||
prefix="¥"
|
||||
:value-style="{ color: '#1890ff' }"
|
||||
/>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Statistic
|
||||
title="已结算金额"
|
||||
:value="statistics.settledAmount"
|
||||
:precision="2"
|
||||
prefix="¥"
|
||||
:value-style="{ color: '#52c41a' }"
|
||||
/>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
{{ statistics.settledCount }} 条记录
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Statistic
|
||||
title="冻结中金额"
|
||||
:value="statistics.frozenAmount"
|
||||
:precision="2"
|
||||
prefix="¥"
|
||||
:value-style="{ color: '#fa8c16' }"
|
||||
/>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
{{ statistics.frozenCount }} 条记录
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row :gutter="[16, 16]" class="mt-4">
|
||||
<Col :span="12">
|
||||
<Statistic
|
||||
title="已退款金额"
|
||||
:value="statistics.refundedAmount"
|
||||
:precision="2"
|
||||
prefix="¥"
|
||||
:value-style="{ color: '#f5222d' }"
|
||||
/>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
{{ statistics.refundedCount }} 条记录
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<!-- 佣金记录列表 -->
|
||||
<Grid :table-title="`代理商 ID: ${modalData?.agentId} 的历史佣金记录`" />
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.commission-history-modal {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import CommissionList from '../../agent-commission/list.vue';
|
||||
|
||||
interface ModalData {
|
||||
agentId: number;
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '佣金记录列表',
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const modalData = computed(() => modalApi.getData<ModalData>());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[calc(100vw-200px)]" :footer="false">
|
||||
<div class="agent-commission-modal">
|
||||
<CommissionList :agent-id="modalData?.agentId" />
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.agent-commission-modal {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
62
apps/web-antd/src/views/agent/agent-list/modules/form.vue
Normal file
62
apps/web-antd/src/views/agent/agent-list/modules/form.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AgentApi } from '#/api/agent';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const formData = ref<AgentApi.AgentListItem>();
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const id = ref();
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
const values = await formApi.getValues();
|
||||
drawerApi.lock();
|
||||
// TODO: 实现更新代理信息的接口
|
||||
// updateAgent(id.value, values as AgentApi.UpdateAgentRequest)
|
||||
// .then(() => {
|
||||
// emit('success');
|
||||
// drawerApi.close();
|
||||
// })
|
||||
// .catch(() => {
|
||||
// drawerApi.unlock();
|
||||
// });
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<AgentApi.AgentListItem>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
id.value = data.id;
|
||||
formApi.setValues(data);
|
||||
} else {
|
||||
id.value = undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getDrawerTitle = computed(() => {
|
||||
return formData.value?.id ? '编辑代理' : '创建代理';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer :title="getDrawerTitle">
|
||||
<Form />
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import LinkList from '../../agent-links/list.vue';
|
||||
|
||||
interface ModalData {
|
||||
agentId: number;
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '推广链接列表',
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const modalData = computed(() => modalApi.getData<ModalData>());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[calc(100vw-200px)]" :footer="false">
|
||||
<div class="agent-link-modal">
|
||||
<LinkList :agent-id="modalData?.agentId" />
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.agent-link-modal {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import PlatformDeductionList from '../../agent-platform-deduction/list.vue';
|
||||
|
||||
interface ModalData {
|
||||
agentId: number;
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '平台抽佣记录',
|
||||
width: 1000,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const modalData = computed(() => modalApi.getData<ModalData>());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal>
|
||||
<PlatformDeductionList v-if="modalData" :agent-id="modalData.agentId" />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-modal-body) {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import RewardList from '../../agent-reward/list.vue';
|
||||
|
||||
interface ModalData {
|
||||
agentId: number;
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '奖励记录',
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const modalData = computed(() => modalApi.getData<ModalData>());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[calc(100vw-200px)]" :footer="false">
|
||||
<div class="agent-reward-modal">
|
||||
<RewardList :agent-id="modalData?.agentId" />
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.agent-reward-modal {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,202 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, h } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getWalletTransactionList } from '#/api/agent';
|
||||
|
||||
interface ModalData {
|
||||
agentId: number;
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '钱包流水记录',
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const modalData = computed(() => modalApi.getData<ModalData>());
|
||||
|
||||
// 交易类型映射
|
||||
const transactionTypeMap: Record<string, { label: string; color: string }> = {
|
||||
commission: { label: '佣金收入', color: 'green' },
|
||||
withdraw: { label: '提现', color: 'red' },
|
||||
freeze: { label: '冻结', color: 'orange' },
|
||||
unfreeze: { label: '解冻', color: 'blue' },
|
||||
reward: { label: '奖励', color: 'green' },
|
||||
refund: { label: '退款', color: 'purple' },
|
||||
adjust: { label: '调整', color: 'cyan' },
|
||||
};
|
||||
|
||||
// 获取交易类型标签
|
||||
function getTransactionTypeTag(type: string) {
|
||||
const config = transactionTypeMap[type] || { label: type, color: 'default' };
|
||||
return h(Tag, { color: config.color }, config.label);
|
||||
}
|
||||
|
||||
// 获取金额显示
|
||||
function getAmountCellRender(params: any) {
|
||||
const amount = params.row.amount;
|
||||
const isPositive = amount >= 0;
|
||||
const color = isPositive ? '#52c41a' : '#ff4d4f';
|
||||
const sign = isPositive ? '+' : '';
|
||||
return h('span', { style: { color, fontWeight: 'bold' } }, `${sign}${amount.toFixed(2)}`);
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
field: 'id',
|
||||
title: '流水ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'transaction_type',
|
||||
title: '交易类型',
|
||||
width: 120,
|
||||
cellRender: {
|
||||
name: 'CustomRender',
|
||||
render: getTransactionTypeTag,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'amount',
|
||||
title: '变动金额',
|
||||
width: 120,
|
||||
cellRender: {
|
||||
name: 'CustomRender',
|
||||
render: getAmountCellRender,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'balance_before',
|
||||
title: '变动前余额',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }: any) => `¥${Number(cellValue || 0).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'balance_after',
|
||||
title: '变动后余额',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }: any) => `¥${Number(cellValue || 0).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'frozen_balance_before',
|
||||
title: '变动前冻结',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }: any) => `¥${Number(cellValue || 0).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'frozen_balance_after',
|
||||
title: '变动后冻结',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }: any) => `¥${Number(cellValue || 0).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'transaction_id',
|
||||
title: '关联交易ID',
|
||||
width: 150,
|
||||
formatter: ({ cellValue }: any) => cellValue || '-',
|
||||
},
|
||||
{
|
||||
field: 'remark',
|
||||
title: '备注',
|
||||
width: 200,
|
||||
formatter: ({ cellValue }: any) => cellValue || '-',
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
|
||||
const [Grid] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'transaction_type',
|
||||
label: '交易类型',
|
||||
help: '如: commission, withdraw, freeze, unfreeze, reward, refund, adjust',
|
||||
},
|
||||
{
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
showTime: true,
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
},
|
||||
fieldName: 'create_time_start',
|
||||
label: '开始时间',
|
||||
},
|
||||
{
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
showTime: true,
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
},
|
||||
fieldName: 'create_time_end',
|
||||
label: '结束时间',
|
||||
},
|
||||
],
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns,
|
||||
height: 600,
|
||||
maxHeight: 800,
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: true,
|
||||
pageSize: 20,
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
layouts: ['Total', 'Sizes', 'PrevJump', 'Number', 'NextJump', 'FullJump'],
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }: { page: { currentPage: number; pageSize: number } }, formValues: Record<string, any>) => {
|
||||
return await getWalletTransactionList({
|
||||
agent_id: modalData.value?.agentId || 0,
|
||||
...formValues,
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
});
|
||||
},
|
||||
},
|
||||
props: {
|
||||
result: 'items',
|
||||
total: 'total',
|
||||
},
|
||||
autoLoad: true,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[calc(100vw-200px)]" :footer="false">
|
||||
<div class="wallet-transaction-modal">
|
||||
<Grid table-title="钱包流水记录" />
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.wallet-transaction-modal {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import WithdrawalList from '../../agent-withdrawal/list.vue';
|
||||
|
||||
interface ModalData {
|
||||
agentId: number;
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '提现记录',
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const modalData = computed(() => modalApi.getData<ModalData>());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[calc(100vw-200px)]" :footer="false">
|
||||
<div class="agent-withdrawal-modal">
|
||||
<WithdrawalList :agent-id="modalData?.agentId" />
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.agent-withdrawal-modal {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
254
apps/web-antd/src/views/agent/agent-membership-config/data.ts
Normal file
254
apps/web-antd/src/views/agent/agent-membership-config/data.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
// 会员等级选项
|
||||
export const levelNameOptions = [
|
||||
{ label: '普通会员', value: 'normal' },
|
||||
{ label: 'VIP会员', value: 'VIP' },
|
||||
{ label: 'SVIP会员', value: 'SVIP' },
|
||||
];
|
||||
|
||||
// 代理会员配置列表列配置
|
||||
export function useColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{
|
||||
field: 'level_name',
|
||||
title: '会员等级',
|
||||
formatter: ({ cellValue }) => {
|
||||
const option = levelNameOptions.find(
|
||||
(item) => item.value === cellValue,
|
||||
);
|
||||
return option?.label || cellValue;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'price',
|
||||
title: '会员年费',
|
||||
formatter: ({ cellValue }) =>
|
||||
cellValue !== null && cellValue !== undefined
|
||||
? `¥${cellValue.toFixed(2)}`
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
field: 'report_commission',
|
||||
title: '直推报告收益',
|
||||
formatter: ({ cellValue }) =>
|
||||
cellValue !== null && cellValue !== undefined
|
||||
? `¥${cellValue.toFixed(2)}`
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
field: 'lower_activity_reward',
|
||||
title: '下级活跃奖励',
|
||||
formatter: ({ cellValue }) =>
|
||||
cellValue !== null && cellValue !== undefined
|
||||
? `¥${cellValue.toFixed(2)}`
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
field: 'new_activity_reward',
|
||||
title: '新增活跃奖励',
|
||||
formatter: ({ cellValue }) =>
|
||||
cellValue !== null && cellValue !== undefined
|
||||
? `¥${cellValue.toFixed(2)}`
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
field: 'lower_standard_count',
|
||||
title: '活跃下级达标数',
|
||||
formatter: ({ cellValue }) => cellValue ?? '-',
|
||||
},
|
||||
{
|
||||
field: 'new_lower_standard_count',
|
||||
title: '新增活跃下级达标数',
|
||||
formatter: ({ cellValue }) => cellValue ?? '-',
|
||||
},
|
||||
{
|
||||
field: 'lower_withdraw_reward_ratio',
|
||||
title: '下级提现奖励比例',
|
||||
formatter: ({ cellValue }) =>
|
||||
cellValue !== null && cellValue !== undefined
|
||||
? `${(cellValue * 100).toFixed(2)}%`
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
slots: { default: 'operation' },
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
title: '操作',
|
||||
width: 120,
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
|
||||
// 代理会员配置搜索表单配置
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'level_name',
|
||||
label: '会员等级',
|
||||
componentProps: {
|
||||
placeholder: '请选择会员等级',
|
||||
options: levelNameOptions,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 代理会员配置编辑表单配置
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'level_name',
|
||||
label: '会员等级',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
options: levelNameOptions,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'price',
|
||||
label: '会员年费',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'report_commission',
|
||||
label: '直推报告收益',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'lower_activity_reward',
|
||||
label: '下级活跃奖励',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'new_activity_reward',
|
||||
label: '新增活跃奖励',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'lower_standard_count',
|
||||
label: '活跃下级达标数',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'new_lower_standard_count',
|
||||
label: '新增活跃下级达标数',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'lower_withdraw_reward_ratio',
|
||||
label: '下级提现奖励比例',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
precision: 2,
|
||||
step: 0.01,
|
||||
addonAfter: '%',
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'lower_convert_vip_reward',
|
||||
label: '下级转化VIP奖励',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'lower_convert_svip_reward',
|
||||
label: '下级转化SVIP奖励',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'exemption_amount',
|
||||
label: '免责金额',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'price_increase_max',
|
||||
label: '提价最高金额',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'price_ratio',
|
||||
label: '提价区间收取比例',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
precision: 2,
|
||||
step: 0.01,
|
||||
addonAfter: '%',
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'price_increase_amount',
|
||||
label: '加价金额',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
105
apps/web-antd/src/views/agent/agent-membership-config/list.vue
Normal file
105
apps/web-antd/src/views/agent/agent-membership-config/list.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeGridListeners,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { AgentApi } from '#/api/agent';
|
||||
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Button, Space } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getAgentMembershipConfigList } from '#/api/agent';
|
||||
|
||||
import { useColumns, useGridFormSchema } from './data';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
// 表单抽屉
|
||||
const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 表格配置
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridEvents: {
|
||||
sortChange: () => {
|
||||
gridApi.query();
|
||||
},
|
||||
} as VxeGridListeners<AgentApi.AgentMembershipConfigListItem>,
|
||||
gridOptions: {
|
||||
columns: useColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const res = await getAgentMembershipConfigList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
level_name: formValues.level_name,
|
||||
});
|
||||
return res;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
result: 'items',
|
||||
total: 'total',
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
} as VxeTableGridOptions<AgentApi.AgentMembershipConfigListItem>,
|
||||
});
|
||||
|
||||
// 操作处理函数
|
||||
function onActionClick(
|
||||
e: OnActionClickParams<AgentApi.AgentMembershipConfigListItem>,
|
||||
) {
|
||||
switch (e.code) {
|
||||
case 'edit': {
|
||||
onEdit(e.row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑处理
|
||||
function onEdit(row: AgentApi.AgentMembershipConfigListItem) {
|
||||
formDrawerApi.setData(row).open();
|
||||
}
|
||||
|
||||
// 刷新处理
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormDrawer @success="onRefresh" />
|
||||
<Grid table-title="代理会员配置列表">
|
||||
<template #operation="{ row }">
|
||||
<Space>
|
||||
<Button type="link" @click="onActionClick({ code: 'edit', row })">
|
||||
配置
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AgentApi } from '#/api/agent';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer, useVbenForm } from '@vben/common-ui';
|
||||
|
||||
import { updateAgentMembershipConfig } from '#/api/agent';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const formData = ref<AgentApi.AgentMembershipConfigListItem>();
|
||||
const id = ref<number>();
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const drawerTitle = ref('会员配置');
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: drawerTitle.value,
|
||||
destroyOnClose: true,
|
||||
async onConfirm() {
|
||||
const valid = await formApi.validate();
|
||||
if (!valid || !id.value) return;
|
||||
|
||||
const values = await formApi.getValues();
|
||||
const params: AgentApi.UpdateAgentMembershipConfigParams = {
|
||||
id: id.value,
|
||||
level_name: values.level_name,
|
||||
price: values.price,
|
||||
report_commission: values.report_commission,
|
||||
lower_activity_reward: values.lower_activity_reward ?? null,
|
||||
new_activity_reward: values.new_activity_reward ?? null,
|
||||
lower_standard_count: values.lower_standard_count ?? null,
|
||||
new_lower_standard_count: values.new_lower_standard_count ?? null,
|
||||
lower_withdraw_reward_ratio:
|
||||
values.lower_withdraw_reward_ratio !== null &&
|
||||
values.lower_withdraw_reward_ratio !== undefined
|
||||
? values.lower_withdraw_reward_ratio / 100
|
||||
: null,
|
||||
lower_convert_vip_reward: values.lower_convert_vip_reward ?? null,
|
||||
lower_convert_svip_reward: values.lower_convert_svip_reward ?? null,
|
||||
exemption_amount: values.exemption_amount ?? null,
|
||||
price_increase_max: values.price_increase_max ?? null,
|
||||
price_ratio:
|
||||
values.price_ratio !== null && values.price_ratio !== undefined
|
||||
? values.price_ratio / 100
|
||||
: null,
|
||||
price_increase_amount: values.price_increase_amount ?? null,
|
||||
};
|
||||
|
||||
await updateAgentMembershipConfig(params);
|
||||
|
||||
emit('success');
|
||||
drawerApi.close();
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<AgentApi.AgentMembershipConfigListItem>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
id.value = data.id;
|
||||
formApi.setValues({
|
||||
...data,
|
||||
lower_withdraw_reward_ratio: data.lower_withdraw_reward_ratio
|
||||
? data.lower_withdraw_reward_ratio * 100
|
||||
: null,
|
||||
price_ratio: data.price_ratio ? data.price_ratio * 100 : null,
|
||||
});
|
||||
} else {
|
||||
id.value = undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer :title="drawerTitle">
|
||||
<Form />
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
// 支付方式选项
|
||||
export const paymentMethodOptions = [
|
||||
{ label: '支付宝', value: 'alipay' },
|
||||
{ label: '微信', value: 'wechat' },
|
||||
{ label: '苹果支付', value: 'appleiap' },
|
||||
{ label: '其他', value: 'other' },
|
||||
];
|
||||
|
||||
// 会员等级选项
|
||||
export const levelNameOptions = [
|
||||
{ label: '普通会员', value: '' },
|
||||
{ label: 'VIP会员', value: 'VIP' },
|
||||
{ label: 'SVIP会员', value: 'SVIP' },
|
||||
];
|
||||
|
||||
// 状态选项
|
||||
export const statusOptions = [
|
||||
{ label: '待支付', value: 'pending' },
|
||||
{ label: '支付成功', value: 'success' },
|
||||
{ label: '支付失败', value: 'failed' },
|
||||
{ label: '已取消', value: 'cancelled' },
|
||||
];
|
||||
|
||||
// 列表列配置
|
||||
export function useMembershipRechargeOrderColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{ field: 'user_id', title: '用户ID', width: 100 },
|
||||
{ field: 'agent_id', title: '代理ID', width: 100 },
|
||||
{
|
||||
field: 'level_name',
|
||||
title: '会员等级',
|
||||
width: 100,
|
||||
formatter: ({ cellValue }) => {
|
||||
const option = levelNameOptions.find(
|
||||
(item) => item.value === cellValue,
|
||||
);
|
||||
return option?.label || '普通会员';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'amount',
|
||||
title: '金额',
|
||||
width: 100,
|
||||
formatter: ({ cellValue }) => `¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'payment_method',
|
||||
title: '支付方式',
|
||||
width: 100,
|
||||
formatter: ({ cellValue }) => {
|
||||
const option = paymentMethodOptions.find(
|
||||
(item) => item.value === cellValue,
|
||||
);
|
||||
return option?.label || cellValue;
|
||||
},
|
||||
},
|
||||
{ field: 'order_no', title: '订单号', width: 180 },
|
||||
{ field: 'platform_order_id', title: '平台订单号', width: 180 },
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
formatter: ({ cellValue }) => {
|
||||
const option = statusOptions.find((item) => item.value === cellValue);
|
||||
return option?.label || cellValue;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 搜索表单配置
|
||||
export function useMembershipRechargeOrderFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'user_id',
|
||||
label: '用户ID',
|
||||
componentProps: {
|
||||
placeholder: '请输入用户ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'agent_id',
|
||||
label: '代理ID',
|
||||
componentProps: {
|
||||
placeholder: '请输入代理ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'order_no',
|
||||
label: '订单号',
|
||||
componentProps: {
|
||||
placeholder: '请输入订单号',
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'platform_order_id',
|
||||
label: '平台订单号',
|
||||
componentProps: {
|
||||
placeholder: '请输入平台订单号',
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
placeholder: '请选择状态',
|
||||
options: statusOptions,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'payment_method',
|
||||
label: '支付方式',
|
||||
componentProps: {
|
||||
placeholder: '请选择支付方式',
|
||||
options: paymentMethodOptions,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { AgentApi } from '#/api/agent/agent';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getMembershipRechargeOrderList } from '#/api/agent';
|
||||
|
||||
import {
|
||||
useMembershipRechargeOrderColumns,
|
||||
useMembershipRechargeOrderFormSchema,
|
||||
} from './data';
|
||||
|
||||
const [Grid, _gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useMembershipRechargeOrderFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useMembershipRechargeOrderColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async (
|
||||
{ page }: { page: { currentPage: number; pageSize: number } },
|
||||
formValues: Record<string, any>,
|
||||
) => {
|
||||
const res = await getMembershipRechargeOrderList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
return { items: res.items, total: res.total };
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
} as VxeGridProps<AgentApi.MembershipRechargeOrderListItem>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid table-title="会员充值订单列表" />
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { AgentApi } from '#/api/agent';
|
||||
|
||||
// 平台抽佣列表列配置
|
||||
export function usePlatformDeductionColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
title: 'ID',
|
||||
field: 'id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '代理ID',
|
||||
field: 'agent_id',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '抽佣金额',
|
||||
field: 'amount',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }) => {
|
||||
return `¥${cellValue.toFixed(2)}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '抽佣类型',
|
||||
field: 'type',
|
||||
width: 120,
|
||||
formatter: ({
|
||||
cellValue,
|
||||
}: {
|
||||
cellValue: AgentApi.AgentPlatformDeductionListItem['type'];
|
||||
}) => {
|
||||
const typeMap = {
|
||||
cost: '成本抽佣',
|
||||
pricing: '定价抽佣',
|
||||
};
|
||||
return typeMap[cellValue] || cellValue;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
field: 'status',
|
||||
width: 100,
|
||||
formatter: ({ cellValue }) => {
|
||||
const statusMap = {
|
||||
0: { text: '待处理', type: 'warning' },
|
||||
1: { text: '已处理', type: 'success' },
|
||||
2: { text: '已取消', type: 'error' },
|
||||
};
|
||||
const status = statusMap[cellValue as keyof typeof statusMap];
|
||||
return status
|
||||
? `<a-tag color="${status.type}">${status.text}</a-tag>`
|
||||
: cellValue;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
field: 'create_time',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 平台抽佣列表搜索表单配置
|
||||
export function usePlatformDeductionFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'type',
|
||||
label: '抽佣类型',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '成本抽佣', value: 'cost' },
|
||||
{ label: '定价抽佣', value: 'pricing' },
|
||||
],
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '待处理', value: 0 },
|
||||
{ label: '已处理', value: 1 },
|
||||
{ label: '已取消', value: 2 },
|
||||
],
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getAgentPlatformDeductionList } from '#/api/agent';
|
||||
|
||||
import {
|
||||
usePlatformDeductionColumns,
|
||||
usePlatformDeductionFormSchema,
|
||||
} from './data';
|
||||
|
||||
interface Props {
|
||||
agentId?: number;
|
||||
}
|
||||
|
||||
interface QueryParams {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const queryParams = computed(() => ({
|
||||
...(props.agentId ? { agent_id: props.agentId } : {}),
|
||||
}));
|
||||
|
||||
const [Grid] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: usePlatformDeductionFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: usePlatformDeductionColumns(),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
form,
|
||||
page,
|
||||
}: {
|
||||
form: Record<string, any>;
|
||||
page: QueryParams;
|
||||
}) => {
|
||||
return await getAgentPlatformDeductionList({
|
||||
...queryParams.value,
|
||||
...form,
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
});
|
||||
},
|
||||
},
|
||||
props: {
|
||||
result: 'items',
|
||||
total: 'total',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page :auto-content-height="!agentId">
|
||||
<Grid :table-title="agentId ? '平台抽佣列表' : '所有平台抽佣记录'" />
|
||||
</Page>
|
||||
</template>
|
||||
131
apps/web-antd/src/views/agent/agent-product-config/data.ts
Normal file
131
apps/web-antd/src/views/agent/agent-product-config/data.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
// 代理产品配置列表列配置
|
||||
export function useColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'id',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'product_name',
|
||||
title: '产品名称',
|
||||
},
|
||||
{
|
||||
field: 'cost_price',
|
||||
title: '成本',
|
||||
formatter: ({ cellValue }: { cellValue: number }) =>
|
||||
`¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'price_range_min',
|
||||
title: '最低定价',
|
||||
formatter: ({ cellValue }: { cellValue: number }) =>
|
||||
`¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'price_range_max',
|
||||
title: '最高定价',
|
||||
formatter: ({ cellValue }: { cellValue: number }) =>
|
||||
`¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'pricing_standard',
|
||||
title: '定价标准',
|
||||
formatter: ({ cellValue }: { cellValue: number }) =>
|
||||
`¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
field: 'overpricing_ratio',
|
||||
title: '超标抽佣比例',
|
||||
formatter: ({ cellValue }: { cellValue: number }) =>
|
||||
`${(cellValue * 100).toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
slots: { default: 'operation' },
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
title: '操作',
|
||||
width: 120,
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
|
||||
// 代理产品配置搜索表单配置
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'product_name',
|
||||
label: '产品名称',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 代理产品配置编辑表单配置
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'cost_price',
|
||||
label: '成本',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'price_range_min',
|
||||
label: '最低定价',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'price_range_max',
|
||||
label: '最高定价',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'pricing_standard',
|
||||
label: '定价标准',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'overpricing_ratio',
|
||||
label: '超标抽佣比例',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
precision: 2,
|
||||
step: 0.01,
|
||||
addonAfter: '%',
|
||||
controls: true,
|
||||
validateTrigger: ['blur', 'change'],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
127
apps/web-antd/src/views/agent/agent-product-config/list.vue
Normal file
127
apps/web-antd/src/views/agent/agent-product-config/list.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeGridListeners,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { AgentApi } from '#/api/agent';
|
||||
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Button, Space } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getAgentProductionConfigList } from '#/api/agent';
|
||||
|
||||
import { useColumns, useGridFormSchema } from './data';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
// 表单抽屉
|
||||
const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 表格配置
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridEvents: {
|
||||
sortChange: () => {
|
||||
gridApi.query();
|
||||
},
|
||||
} as VxeGridListeners<AgentApi.AgentProductionConfigItem>,
|
||||
gridOptions: {
|
||||
columns: useColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
sortConfig: {
|
||||
remote: true,
|
||||
multiple: false,
|
||||
trigger: 'default',
|
||||
orders: ['asc', 'desc', null],
|
||||
resetPage: true,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page, sort }, formValues) => {
|
||||
const sortParams = sort
|
||||
? {
|
||||
order_by: sort.field,
|
||||
order_type: sort.order,
|
||||
}
|
||||
: {};
|
||||
|
||||
const params: AgentApi.GetAgentProductionConfigListParams = {
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
product_name: formValues.product_name,
|
||||
...sortParams,
|
||||
};
|
||||
|
||||
const res = await getAgentProductionConfigList(params);
|
||||
|
||||
return {
|
||||
...res,
|
||||
sort: sort || null,
|
||||
};
|
||||
},
|
||||
},
|
||||
props: {
|
||||
result: 'items',
|
||||
total: 'total',
|
||||
},
|
||||
autoLoad: true,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
} as VxeTableGridOptions<AgentApi.AgentProductionConfigItem>,
|
||||
});
|
||||
|
||||
// 操作处理函数
|
||||
function onActionClick(
|
||||
e: OnActionClickParams<AgentApi.AgentProductionConfigItem>,
|
||||
) {
|
||||
switch (e.code) {
|
||||
case 'edit': {
|
||||
onEdit(e.row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑处理
|
||||
function onEdit(row: AgentApi.AgentProductionConfigItem) {
|
||||
formDrawerApi.setData(row).open();
|
||||
}
|
||||
|
||||
// 刷新处理
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormDrawer @success="onRefresh" />
|
||||
<Grid table-title="代理产品配置列表">
|
||||
<template #operation="{ row }">
|
||||
<Space>
|
||||
<Button type="link" @click="onActionClick({ code: 'edit', row })">
|
||||
配置
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AgentApi } from '#/api/agent';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer, useVbenForm } from '@vben/common-ui';
|
||||
|
||||
import { updateAgentProductionConfig } from '#/api/agent';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const formData = ref<AgentApi.AgentProductionConfigItem>();
|
||||
const id = ref<number>();
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
const drawerTitle = ref('产品配置');
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
title: drawerTitle.value,
|
||||
destroyOnClose: true,
|
||||
async onConfirm() {
|
||||
const valid = await formApi.validate();
|
||||
if (!valid || !id.value) return;
|
||||
|
||||
const values = await formApi.getValues();
|
||||
const params: AgentApi.UpdateAgentProductionConfigParams = {
|
||||
id: id.value,
|
||||
cost_price: values.cost_price,
|
||||
price_range_min: values.price_range_min,
|
||||
price_range_max: values.price_range_max,
|
||||
pricing_standard: values.pricing_standard,
|
||||
overpricing_ratio: values.overpricing_ratio / 100,
|
||||
};
|
||||
|
||||
await updateAgentProductionConfig(params);
|
||||
|
||||
emit('success');
|
||||
drawerApi.close();
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<AgentApi.AgentProductionConfigItem>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
id.value = data.id;
|
||||
formApi.setValues({
|
||||
...data,
|
||||
overpricing_ratio: data.overpricing_ratio * 100,
|
||||
});
|
||||
} else {
|
||||
id.value = undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer :title="drawerTitle">
|
||||
<Form />
|
||||
</Drawer>
|
||||
</template>
|
||||
74
apps/web-antd/src/views/agent/agent-reward/data.ts
Normal file
74
apps/web-antd/src/views/agent/agent-reward/data.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
export function useRewardColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
title: '代理ID',
|
||||
field: 'agent_id',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '奖励类型',
|
||||
field: 'type',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '奖励金额',
|
||||
field: 'amount',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }) => `¥${cellValue.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '关联订单',
|
||||
field: 'order_id',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
field: 'status',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
field: 'create_time',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '发放时间',
|
||||
field: 'pay_time',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useRewardFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'type',
|
||||
label: '奖励类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '注册奖励', value: 'register' },
|
||||
{ label: '首单奖励', value: 'first_order' },
|
||||
{ label: '升级奖励', value: 'level_up' },
|
||||
],
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '待发放', value: 'pending' },
|
||||
{ label: '已发放', value: 'paid' },
|
||||
{ label: '发放失败', value: 'failed' },
|
||||
],
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
64
apps/web-antd/src/views/agent/agent-reward/list.vue
Normal file
64
apps/web-antd/src/views/agent/agent-reward/list.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getAgentRewardList } from '#/api/agent';
|
||||
|
||||
import { useRewardColumns, useRewardFormSchema } from './data';
|
||||
|
||||
interface Props {
|
||||
agentId?: number;
|
||||
}
|
||||
|
||||
interface QueryParams {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const queryParams = computed(() => ({
|
||||
...(props.agentId ? { agent_id: props.agentId } : {}),
|
||||
}));
|
||||
|
||||
const [Grid] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useRewardFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useRewardColumns(),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
form,
|
||||
}: {
|
||||
form: Record<string, any>;
|
||||
page: QueryParams;
|
||||
}) => {
|
||||
return await getAgentRewardList({
|
||||
...queryParams.value,
|
||||
...form,
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
});
|
||||
},
|
||||
},
|
||||
props: {
|
||||
result: 'items',
|
||||
total: 'total',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page :auto-content-height="!agentId">
|
||||
<Grid :table-title="agentId ? '奖励记录列表' : '所有奖励记录'" />
|
||||
</Page>
|
||||
</template>
|
||||
161
apps/web-antd/src/views/agent/agent-withdrawal/data.ts
Normal file
161
apps/web-antd/src/views/agent/agent-withdrawal/data.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
export function useWithdrawalColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
title: '代理ID',
|
||||
field: 'agent_id',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '提现金额',
|
||||
field: 'amount',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }) => `¥${cellValue?.toFixed(2) || '0.00'}`,
|
||||
},
|
||||
{
|
||||
title: '扣税金额',
|
||||
field: 'tax_amount',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }) => `¥${cellValue?.toFixed(2) || '0.00'}`,
|
||||
},
|
||||
{
|
||||
title: '实际转账金额',
|
||||
field: 'actual_amount',
|
||||
width: 120,
|
||||
formatter: ({ cellValue }) => `¥${cellValue?.toFixed(2) || '0.00'}`,
|
||||
},
|
||||
{
|
||||
title: '提现类型',
|
||||
field: 'withdraw_type',
|
||||
width: 100,
|
||||
formatter: ({ cellValue }) => {
|
||||
const typeMap: Record<number, string> = {
|
||||
1: '支付宝',
|
||||
2: '银行卡',
|
||||
};
|
||||
return typeMap[cellValue] || '未知';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '收款信息',
|
||||
field: 'payee_info',
|
||||
width: 200,
|
||||
formatter: ({ row }) => {
|
||||
if (row.withdraw_type === 1) {
|
||||
// 支付宝提现
|
||||
return row.payee_account || '-';
|
||||
} else if (row.withdraw_type === 2) {
|
||||
// 银行卡提现
|
||||
if (row.bank_card_no) {
|
||||
const cardNo = row.bank_card_no.replaceAll(/\s/g, '');
|
||||
const masked =
|
||||
cardNo.length > 8
|
||||
? `${cardNo.slice(0, 4)} **** **** ${cardNo.slice(
|
||||
Math.max(0, cardNo.length - 4),
|
||||
)}`
|
||||
: cardNo;
|
||||
return `${masked}${row.bank_name ? ` (${row.bank_name})` : ''}`;
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
return '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '收款人',
|
||||
field: 'payee_name',
|
||||
width: 120,
|
||||
formatter: ({ cellValue, row }) => {
|
||||
if (row.withdraw_type === 2 && cellValue) {
|
||||
return cellValue;
|
||||
}
|
||||
return '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
field: 'status',
|
||||
width: 100,
|
||||
formatter: ({ cellValue }) => {
|
||||
const statusMap: Record<number, string> = {
|
||||
1: '申请中',
|
||||
2: '成功',
|
||||
3: '失败',
|
||||
};
|
||||
return statusMap[cellValue] || '未知';
|
||||
},
|
||||
cellRender: {
|
||||
name: 'VxeTag',
|
||||
props: ({ row }) => {
|
||||
const statusConfig: Record<
|
||||
number,
|
||||
{ content: string; type: string }
|
||||
> = {
|
||||
1: { type: 'warning', content: '申请中' },
|
||||
2: { type: 'success', content: '成功' },
|
||||
3: { type: 'error', content: '失败' },
|
||||
};
|
||||
const config = statusConfig[row.status] || {
|
||||
type: 'info',
|
||||
content: '未知',
|
||||
};
|
||||
return {
|
||||
type: config.type,
|
||||
content: config.content,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '申请时间',
|
||||
field: 'create_time',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
field: 'remark',
|
||||
width: 200,
|
||||
formatter: ({ cellValue }) => cellValue || '-',
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
slots: { default: 'operation' },
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
title: '操作',
|
||||
width: 120,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useWithdrawalFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'withdraw_type',
|
||||
label: '提现类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '支付宝', value: 1 },
|
||||
{ label: '银行卡', value: 2 },
|
||||
],
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '申请中', value: 1 },
|
||||
{ label: '成功', value: 2 },
|
||||
{ label: '失败', value: 3 },
|
||||
],
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
105
apps/web-antd/src/views/agent/agent-withdrawal/list.vue
Normal file
105
apps/web-antd/src/views/agent/agent-withdrawal/list.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
import { Button, Space } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getAgentWithdrawalList } from '#/api/agent';
|
||||
|
||||
import ReviewModal from './modules/review-modal.vue';
|
||||
import { useWithdrawalColumns, useWithdrawalFormSchema } from './data';
|
||||
|
||||
interface Props {
|
||||
agentId?: number;
|
||||
}
|
||||
|
||||
interface QueryParams {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const queryParams = computed(() => ({
|
||||
...(props.agentId ? { agent_id: props.agentId } : {}),
|
||||
}));
|
||||
|
||||
// 审核弹窗
|
||||
const [ReviewDrawer, reviewDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: ReviewModal,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 操作处理
|
||||
function onActionClick(e: any) {
|
||||
const { code, row } = e;
|
||||
switch (code) {
|
||||
case 'review':
|
||||
// 打开审核弹窗
|
||||
reviewDrawerApi.setData(row).open();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新列表
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useWithdrawalFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useWithdrawalColumns(),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: QueryParams;
|
||||
}) => {
|
||||
return await getAgentWithdrawalList({
|
||||
...queryParams.value,
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
});
|
||||
},
|
||||
},
|
||||
props: {
|
||||
result: 'items',
|
||||
total: 'total',
|
||||
},
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page :auto-content-height="!agentId">
|
||||
<ReviewDrawer @success="onRefresh" />
|
||||
<Grid :table-title="agentId ? '提现记录列表' : '所有提现记录'">
|
||||
<template #operation="{ row }">
|
||||
<Space>
|
||||
<Button
|
||||
v-if="row.status === 1"
|
||||
type="link"
|
||||
@click="onActionClick({ code: 'review', row })"
|
||||
>
|
||||
审核
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,249 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { reviewBankCardWithdrawal } from '#/api/agent';
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'success'): void;
|
||||
}>();
|
||||
|
||||
const formData = ref<any>(null);
|
||||
|
||||
// 表单配置(包含所有字段)
|
||||
const getFormSchema = (): VbenFormSchema[] => [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'withdraw_no',
|
||||
label: '提现单号',
|
||||
componentProps: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'withdraw_type',
|
||||
label: '提现类型',
|
||||
componentProps: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'amount',
|
||||
label: '提现金额',
|
||||
componentProps: {
|
||||
disabled: true,
|
||||
min: 0,
|
||||
precision: 2,
|
||||
style: { width: '100%' },
|
||||
addonBefore: '¥',
|
||||
},
|
||||
},
|
||||
// 银行卡和支付宝通用字段:扣税金额
|
||||
{
|
||||
component: 'InputNumber' ,
|
||||
fieldName: 'tax_amount',
|
||||
label: '扣税金额',
|
||||
componentProps: {
|
||||
disabled: true,
|
||||
min: 0,
|
||||
precision: 2,
|
||||
style: { width: '100%' },
|
||||
addonBefore: '¥',
|
||||
},
|
||||
},
|
||||
// 银行卡和支付宝通用字段:实际转账金额
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'actual_amount',
|
||||
label: '实际转账金额',
|
||||
componentProps: {
|
||||
disabled: true,
|
||||
min: 0,
|
||||
precision: 2,
|
||||
style: { width: '100%' },
|
||||
addonBefore: '¥',
|
||||
},
|
||||
},
|
||||
// 银行卡和支付宝都有:收款账号
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'payee_account',
|
||||
label: '收款账号',
|
||||
componentProps: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
// 银行卡提现特有字段:开户支行
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'bank_name',
|
||||
label: '开户支行',
|
||||
componentProps: {
|
||||
disabled: true,
|
||||
},
|
||||
dependencies: {
|
||||
show: (values) => values.withdraw_type === '银行卡',
|
||||
triggerFields: ['withdraw_type'],
|
||||
},
|
||||
},
|
||||
// 银行卡提现特有字段:收款人姓名
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'payee_name',
|
||||
label: '收款人姓名',
|
||||
componentProps: {
|
||||
disabled: true,
|
||||
},
|
||||
dependencies: {
|
||||
show: (values) => values.withdraw_type === '银行卡',
|
||||
triggerFields: ['withdraw_type'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
fieldName: 'action',
|
||||
label: '审核操作',
|
||||
defaultValue: 1,
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '确认提现', value: 1 },
|
||||
{ label: '拒绝提现', value: 2 },
|
||||
],
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
componentProps: {
|
||||
rows: 4,
|
||||
placeholder: '拒绝提现时,请填写拒绝原因',
|
||||
maxLength: 200,
|
||||
showCount: true,
|
||||
style: { width: '100%' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: getFormSchema(),
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'agent-withdrawal-form-wrapper',
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
class: 'agent-withdrawal-review-drawer',
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
|
||||
if (!formData.value?.id) {
|
||||
message.error('提现记录ID不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await formApi.getValues<{
|
||||
action: 1 | 2;
|
||||
remark: string;
|
||||
}>();
|
||||
|
||||
// 验证拒绝时必须填写原因
|
||||
if (values.action === 2 && !values.remark?.trim()) {
|
||||
message.error('拒绝提现必须填写拒绝原因');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await reviewBankCardWithdrawal({
|
||||
action: values.action,
|
||||
remark: values.remark || '',
|
||||
withdrawal_id: formData.value.id,
|
||||
});
|
||||
message.success(values.action === 1 ? '确认提现成功' : '拒绝提现成功');
|
||||
drawerApi.close();
|
||||
emit('success');
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '操作失败');
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<any>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
|
||||
// 根据提现类型设置不同的表单初始值
|
||||
const typeMap: Record<number, string> = {
|
||||
1: '支付宝',
|
||||
2: '银行卡',
|
||||
};
|
||||
const initialValues: any = {
|
||||
withdraw_no: data.withdraw_no || '',
|
||||
withdraw_type: typeMap[data.withdraw_type] || '未知',
|
||||
amount: data.amount || 0,
|
||||
action: 1, // 默认选择确认
|
||||
remark: '',
|
||||
};
|
||||
|
||||
// 银行卡提现特有字段
|
||||
if (data.withdraw_type === 2) {
|
||||
initialValues.tax_amount = data.tax_amount || 0;
|
||||
initialValues.actual_amount = data.actual_amount || 0;
|
||||
initialValues.payee_account = data.bank_card_no || '';
|
||||
initialValues.bank_name = data.bank_name || '';
|
||||
initialValues.payee_name = data.payee_name || '';
|
||||
} else {
|
||||
// 支付宝提现
|
||||
initialValues.tax_amount = data.tax_amount || 0;
|
||||
initialValues.actual_amount = data.actual_amount || 0;
|
||||
initialValues.payee_account = data.payee_account || '';
|
||||
}
|
||||
|
||||
formApi.setValues(initialValues);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getDrawerTitle = computed(() => {
|
||||
const typeMap: Record<number, string> = {
|
||||
1: '支付宝',
|
||||
2: '银行卡',
|
||||
};
|
||||
const typeName = typeMap[formData.value?.withdraw_type] || '未知';
|
||||
return `${typeName}提现审核 ${formData.value?.withdraw_no || ''}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer :title="getDrawerTitle" :width="800">
|
||||
<div class="agent-withdrawal-review-content">
|
||||
<Form />
|
||||
</div>
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agent-withdrawal-review-content {
|
||||
padding: 0 4px;
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.agent-withdrawal-review-content :deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.agent-withdrawal-review-content :deep(.ant-form-item-label) {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
216
apps/web-antd/src/views/dashboard/analytics/analytics-trends.vue
Normal file
216
apps/web-antd/src/views/dashboard/analytics/analytics-trends.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
import { statsHistory, statsTotal } from '#/api/promotion/analytics';
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
// 获取30天前的日期
|
||||
const getDateString = (daysAgo: number) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 获取趋势数据
|
||||
const endDate = getDateString(0); // 今天
|
||||
const startDate = getDateString(29); // 29天前
|
||||
const trendData = await statsHistory({ start_date: startDate, end_date: endDate });
|
||||
|
||||
// 获取统计数据
|
||||
const statsData = await statsTotal();
|
||||
|
||||
// 准备图表数据
|
||||
const dates = Array.from({ length: 30 }).map((_, index) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 29 + index);
|
||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
});
|
||||
|
||||
// 如果有历史数据,使用历史数据;否则使用模拟数据
|
||||
let clickData = Array(30).fill(0);
|
||||
if (trendData && trendData.length > 0) {
|
||||
// 将历史数据按日期排序并映射到数组
|
||||
const sortedData = trendData.sort((a, b) =>
|
||||
new Date(a.stats_date).getTime() - new Date(b.stats_date).getTime()
|
||||
);
|
||||
|
||||
sortedData.forEach((item) => {
|
||||
const itemDate = new Date(item.stats_date);
|
||||
const today = new Date();
|
||||
const daysDiff = Math.floor((today.getTime() - itemDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysDiff >= 0 && daysDiff < 30) {
|
||||
// 使用实际日期索引
|
||||
clickData[29 - daysDiff] = item.click_count || 0;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 没有历史数据时,使用统计数据生成模拟数据
|
||||
const todayClickCount = statsData?.today_click_count || 0;
|
||||
const totalClickCount = statsData?.total_click_count || 0;
|
||||
|
||||
// 简单的线性分布模拟数据
|
||||
for (let i = 0; i < 30; i++) {
|
||||
// 最后一天使用今日数据,其他天按比例分布
|
||||
if (i === 29) {
|
||||
clickData[i] = todayClickCount;
|
||||
} else {
|
||||
// 按指数衰减模拟历史数据
|
||||
clickData[i] = Math.max(0, Math.floor(todayClickCount * Math.exp(-0.05 * (29 - i))));
|
||||
}
|
||||
}
|
||||
|
||||
// 确保总和不超过总计数
|
||||
const sum = clickData.reduce((a, b) => a + b, 0);
|
||||
if (sum > totalClickCount && totalClickCount > 0) {
|
||||
const ratio = totalClickCount / sum;
|
||||
clickData = clickData.map(val => Math.floor(val * ratio));
|
||||
}
|
||||
}
|
||||
|
||||
// 计算Y轴最大值
|
||||
const maxValue = Math.max(...clickData) || 10;
|
||||
|
||||
renderEcharts({
|
||||
grid: {
|
||||
bottom: 0,
|
||||
containLabel: true,
|
||||
left: '1%',
|
||||
right: '1%',
|
||||
top: '2%',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
areaStyle: {},
|
||||
data: clickData,
|
||||
itemStyle: {
|
||||
color: '#5ab1ef',
|
||||
},
|
||||
smooth: true,
|
||||
type: 'line',
|
||||
name: '推广访问量',
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
color: '#5ab1ef',
|
||||
width: 1,
|
||||
},
|
||||
},
|
||||
trigger: 'axis',
|
||||
formatter: (params: any) => {
|
||||
const param = params[0];
|
||||
return `${param.axisValue}<br/>${param.seriesName}: ${param.value}`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
boundaryGap: false,
|
||||
data: dates,
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'solid',
|
||||
width: 1,
|
||||
},
|
||||
show: true,
|
||||
},
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
max: Math.ceil(maxValue * 1.2), // 比最大值大20%作为Y轴上限
|
||||
splitArea: {
|
||||
show: true,
|
||||
},
|
||||
splitNumber: 4,
|
||||
type: 'value',
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取推广趋势数据失败:', error);
|
||||
|
||||
// 发生错误时显示默认图表
|
||||
renderEcharts({
|
||||
grid: {
|
||||
bottom: 0,
|
||||
containLabel: true,
|
||||
left: '1%',
|
||||
right: '1%',
|
||||
top: '2%',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
areaStyle: {},
|
||||
data: Array(30).fill(0),
|
||||
itemStyle: {
|
||||
color: '#5ab1ef',
|
||||
},
|
||||
smooth: true,
|
||||
type: 'line',
|
||||
name: '推广访问量',
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
color: '#5ab1ef',
|
||||
width: 1,
|
||||
},
|
||||
},
|
||||
trigger: 'axis',
|
||||
},
|
||||
xAxis: {
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
boundaryGap: false,
|
||||
data: Array.from({ length: 30 }).map((_, index) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 29 + index);
|
||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
}),
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'solid',
|
||||
width: 1,
|
||||
},
|
||||
show: true,
|
||||
},
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
max: 10,
|
||||
splitArea: {
|
||||
show: true,
|
||||
},
|
||||
splitNumber: 4,
|
||||
type: 'value',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EchartsUI ref="chartRef" />
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
onMounted(() => {
|
||||
renderEcharts({
|
||||
legend: {
|
||||
bottom: 0,
|
||||
data: ['渠道A', '渠道B', '渠道C', '渠道D'],
|
||||
},
|
||||
radar: {
|
||||
indicator: [
|
||||
{ name: '渠道A' },
|
||||
{ name: '渠道B' },
|
||||
{ name: '渠道C' },
|
||||
{ name: '渠道D' },
|
||||
],
|
||||
radius: '60%',
|
||||
splitNumber: 8,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
areaStyle: {
|
||||
opacity: 1,
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'rgba(0,0,0,.2)',
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 10,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
itemStyle: { color: '#b6a2de' },
|
||||
name: '渠道A',
|
||||
value: [90, 50, 86, 40],
|
||||
},
|
||||
{
|
||||
itemStyle: { color: '#5ab1ef' },
|
||||
name: '渠道B',
|
||||
value: [70, 75, 70, 76],
|
||||
},
|
||||
{
|
||||
itemStyle: { color: '#67e0e3' },
|
||||
name: '渠道C',
|
||||
value: [60, 65, 60, 66],
|
||||
},
|
||||
{
|
||||
itemStyle: { color: '#2ec7c9' },
|
||||
name: '渠道D',
|
||||
value: [50, 55, 50, 56],
|
||||
},
|
||||
],
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderWidth: 2,
|
||||
},
|
||||
symbolSize: 0,
|
||||
type: 'radar',
|
||||
},
|
||||
],
|
||||
tooltip: {},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EchartsUI ref="chartRef" />
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
onMounted(() => {
|
||||
renderEcharts({
|
||||
series: [
|
||||
{
|
||||
animationDelay() {
|
||||
return Math.random() * 400;
|
||||
},
|
||||
animationEasing: 'exponentialInOut',
|
||||
animationType: 'scale',
|
||||
center: ['50%', '50%'],
|
||||
color: ['#5ab1ef', '#b6a2de', '#67e0e3'],
|
||||
data: [
|
||||
{ name: '佣金', value: 500 },
|
||||
{ name: '奖励', value: 310 },
|
||||
{ name: '提现', value: 274 },
|
||||
],
|
||||
name: '金额占比',
|
||||
radius: '80%',
|
||||
roseType: 'radius',
|
||||
type: 'pie',
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EchartsUI ref="chartRef" />
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
import { getOrderSourceStatistics } from '#/api/order/order';
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
const loading = ref(false);
|
||||
|
||||
// 获取订单来源统计数据
|
||||
async function fetchOrderSourceStatistics() {
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await getOrderSourceStatistics();
|
||||
|
||||
// 提取产品名称和订单数量
|
||||
const data = response.items.map(item => ({
|
||||
name: item.product_name,
|
||||
value: item.order_count,
|
||||
}));
|
||||
|
||||
// 如果有数据,则渲染图表
|
||||
if (data && data.length > 0) {
|
||||
renderEcharts({
|
||||
legend: {
|
||||
bottom: '2%',
|
||||
left: 'center',
|
||||
data: data.map(item => item.name),
|
||||
},
|
||||
series: [
|
||||
{
|
||||
animationDelay() {
|
||||
return Math.random() * 100;
|
||||
},
|
||||
animationEasing: 'exponentialInOut',
|
||||
animationType: 'scale',
|
||||
avoidLabelOverlap: false,
|
||||
color: [
|
||||
'#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9',
|
||||
'#ffb980', '#d87a80', '#8d98b3', '#e5cf0d',
|
||||
'#97b552', '#95706d', '#dc69aa', '#07a2a4',
|
||||
'#9a7fd1', '#588dd5', '#f5994e', '#c05050'
|
||||
],
|
||||
data: data,
|
||||
emphasis: {
|
||||
label: {
|
||||
fontSize: '12',
|
||||
fontWeight: 'bold',
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: {
|
||||
position: 'center',
|
||||
show: false,
|
||||
},
|
||||
labelLine: {
|
||||
show: false,
|
||||
},
|
||||
name: '订单来源',
|
||||
radius: ['40%', '65%'],
|
||||
type: 'pie',
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} ({d}%)',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取订单来源统计数据失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchOrderSourceStatistics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="flex justify-center items-center h-64">
|
||||
加载中...
|
||||
</div>
|
||||
<EchartsUI v-else ref="chartRef" />
|
||||
</template>
|
||||
262
apps/web-antd/src/views/dashboard/analytics/analytics-visits.vue
Normal file
262
apps/web-antd/src/views/dashboard/analytics/analytics-visits.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
import { Button } from 'ant-design-vue';
|
||||
import { getOrderStatistics } from '#/api/order/order-statistics';
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
// 时间维度状态
|
||||
const timeDimension = ref<'day' | 'month' | 'year' | 'all'>('day');
|
||||
|
||||
// 获取订单统计数据
|
||||
async function fetchOrderStatistics() {
|
||||
try {
|
||||
console.log('Fetching order statistics with dimension:', timeDimension.value);
|
||||
|
||||
// 使用后端API获取数据
|
||||
const response = await getOrderStatistics(timeDimension.value);
|
||||
console.log('Order statistics response:', response);
|
||||
|
||||
let items = response.items || [];
|
||||
|
||||
// 如果后端返回空数据,显示空状态
|
||||
if (items.length === 0) {
|
||||
console.log('No data from backend, showing empty chart');
|
||||
items = [];
|
||||
}
|
||||
|
||||
console.log('Items for chart:', items);
|
||||
|
||||
// 按日期排序
|
||||
items.sort((a: any, b: any) => a.date.localeCompare(b.date));
|
||||
|
||||
// 提取日期和数量
|
||||
const dates = items.map((item: any) => {
|
||||
// 根据时间维度格式化日期显示
|
||||
const date = new Date(item.date);
|
||||
if (timeDimension.value === 'year') {
|
||||
return `${date.getFullYear()}`;
|
||||
} else if (timeDimension.value === 'month') {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
} else {
|
||||
return `${date.getMonth() + 1}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
});
|
||||
|
||||
const orderData = items.map((item: any) => item.count);
|
||||
const amountData = items.map((item: any) => item.amount);
|
||||
|
||||
// 计算Y轴最大值
|
||||
const maxOrderValue = Math.max(...orderData) || 10;
|
||||
const maxAmountValue = Math.max(...amountData) || 10;
|
||||
|
||||
renderEcharts({
|
||||
grid: {
|
||||
bottom: 0,
|
||||
containLabel: true,
|
||||
left: '1%',
|
||||
right: '1%',
|
||||
top: '10%',
|
||||
},
|
||||
legend: {
|
||||
data: ['订单数', '订单金额'],
|
||||
top: 0,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: orderData,
|
||||
type: 'line',
|
||||
name: '订单数',
|
||||
smooth: true,
|
||||
itemStyle: {
|
||||
color: '#4f9cff',
|
||||
},
|
||||
areaStyle: {
|
||||
opacity: 0.3,
|
||||
color: '#4f9cff',
|
||||
},
|
||||
},
|
||||
{
|
||||
data: amountData,
|
||||
type: 'line',
|
||||
name: '订单金额',
|
||||
smooth: true,
|
||||
itemStyle: {
|
||||
color: '#52c41a',
|
||||
},
|
||||
areaStyle: {
|
||||
opacity: 0.3,
|
||||
color: '#52c41a',
|
||||
},
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
},
|
||||
},
|
||||
trigger: 'axis',
|
||||
formatter: (params: any) => {
|
||||
let result = `${params[0].axisValue}<br/>`;
|
||||
params.forEach((param: any) => {
|
||||
if (param.seriesName === '订单金额') {
|
||||
result += `${param.seriesName}: ¥${param.value.toFixed(2)}<br/>`;
|
||||
} else {
|
||||
result += `${param.seriesName}: ${param.value}<br/>`;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
data: dates,
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '订单数',
|
||||
position: 'left',
|
||||
max: Math.ceil(maxOrderValue * 1.2),
|
||||
splitNumber: 4,
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '金额(¥)',
|
||||
position: 'right',
|
||||
max: Math.ceil(maxAmountValue * 1.2),
|
||||
splitNumber: 4,
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取订单趋势数据失败:', error);
|
||||
|
||||
// 发生错误时显示默认图表
|
||||
renderEcharts({
|
||||
grid: {
|
||||
bottom: 0,
|
||||
containLabel: true,
|
||||
left: '1%',
|
||||
right: '1%',
|
||||
top: '10%',
|
||||
},
|
||||
legend: {
|
||||
data: ['订单数', '订单金额'],
|
||||
top: 0,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: Array(30).fill(0),
|
||||
type: 'line',
|
||||
name: '订单数',
|
||||
smooth: true,
|
||||
itemStyle: {
|
||||
color: '#4f9cff',
|
||||
},
|
||||
areaStyle: {
|
||||
opacity: 0.3,
|
||||
color: '#4f9cff',
|
||||
},
|
||||
},
|
||||
{
|
||||
data: Array(30).fill(0),
|
||||
type: 'line',
|
||||
name: '订单金额',
|
||||
smooth: true,
|
||||
itemStyle: {
|
||||
color: '#52c41a',
|
||||
},
|
||||
areaStyle: {
|
||||
opacity: 0.3,
|
||||
color: '#52c41a',
|
||||
},
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
},
|
||||
},
|
||||
trigger: 'axis',
|
||||
},
|
||||
xAxis: {
|
||||
data: Array.from({ length: 30 }).map((_, index) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 29 + index);
|
||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
}),
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '订单数',
|
||||
position: 'left',
|
||||
max: 10,
|
||||
splitNumber: 4,
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '金额(¥)',
|
||||
position: 'right',
|
||||
max: 10,
|
||||
splitNumber: 4,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchOrderStatistics();
|
||||
});
|
||||
|
||||
// 监听时间维度变化
|
||||
watch(timeDimension, () => {
|
||||
fetchOrderStatistics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-4 flex justify-end space-x-2">
|
||||
<Button
|
||||
:type="timeDimension === 'day' ? 'primary' : 'default'"
|
||||
@click="timeDimension = 'day'"
|
||||
>
|
||||
日
|
||||
</Button>
|
||||
<Button
|
||||
:type="timeDimension === 'month' ? 'primary' : 'default'"
|
||||
@click="timeDimension = 'month'"
|
||||
>
|
||||
月
|
||||
</Button>
|
||||
<Button
|
||||
:type="timeDimension === 'year' ? 'primary' : 'default'"
|
||||
@click="timeDimension = 'year'"
|
||||
>
|
||||
年
|
||||
</Button>
|
||||
<Button
|
||||
:type="timeDimension === 'all' ? 'primary' : 'default'"
|
||||
@click="timeDimension = 'all'"
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
</div>
|
||||
<EchartsUI ref="chartRef" />
|
||||
</div>
|
||||
</template>
|
||||
255
apps/web-antd/src/views/dashboard/analytics/index.vue
Normal file
255
apps/web-antd/src/views/dashboard/analytics/index.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AnalysisOverviewItem } from '@vben/common-ui';
|
||||
import type { TabOption } from '@vben/types';
|
||||
|
||||
import {
|
||||
AnalysisChartCard,
|
||||
AnalysisChartsTabs,
|
||||
AnalysisOverview,
|
||||
} from '@vben/common-ui';
|
||||
import {
|
||||
SvgBellIcon,
|
||||
SvgCakeIcon,
|
||||
SvgCardIcon,
|
||||
SvgDownloadIcon,
|
||||
} from '@vben/icons';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import AnalyticsTrends from './analytics-trends.vue';
|
||||
import AnalyticsVisitsData from './analytics-visits-data.vue';
|
||||
import AnalyticsVisitsSales from './analytics-visits-sales.vue';
|
||||
import AnalyticsVisitsSource from './analytics-visits-source.vue';
|
||||
import AnalyticsVisits from './analytics-visits.vue';
|
||||
|
||||
import { getAgentStatistics, getWithdrawalStatistics, getAgentOrderStatistics } from '#/api/agent';
|
||||
import { getOrderList, getRefundStatistics, getIncomeStatistics } from '#/api/order/order';
|
||||
import { getPlatformUserList } from '#/api/platform-user';
|
||||
|
||||
// 初始化概览数据
|
||||
const overviewItems = ref<AnalysisOverviewItem[]>([
|
||||
{
|
||||
icon: SvgCardIcon,
|
||||
title: '总用户数',
|
||||
value: 0,
|
||||
todaytitle: '今日新增用户数',
|
||||
todayValue: 0,
|
||||
Subtitle: '总代理数',
|
||||
SubValue: 0,
|
||||
todaySubtitle: '今日新增代理数',
|
||||
todaySubValue: 0,
|
||||
},
|
||||
{
|
||||
icon: SvgCakeIcon,
|
||||
title: '总订单数',
|
||||
value: 0,
|
||||
todaytitle: '今日新增订单数',
|
||||
todayValue: 0,
|
||||
Subtitle: '代理总订单量',
|
||||
SubValue: 0,
|
||||
todaySubtitle: '今日新增代理订单量',
|
||||
todaySubValue: 0,
|
||||
},
|
||||
{
|
||||
icon: SvgDownloadIcon,
|
||||
title: '总收入流水金额',
|
||||
value: 0,
|
||||
todaytitle: '今日新增收入流水金额',
|
||||
todayValue: 0,
|
||||
Subtitle: '总利润',
|
||||
SubValue: 0,
|
||||
todaySubtitle: '今日新增利润',
|
||||
todaySubValue: 0,
|
||||
},
|
||||
{
|
||||
icon: SvgBellIcon,
|
||||
title: '总提现金额',
|
||||
value: 0,
|
||||
SubValue: 0,
|
||||
todaySubtitle: '总实际到账金额',
|
||||
todaySubValue: 0,
|
||||
extraTitle: '总扣税金额',
|
||||
extraValue: 0,
|
||||
todaytitle: '今日新增提现金额',
|
||||
todayValue: 0,
|
||||
Subtitle: '总退款金额',
|
||||
extra2Title: '今日新增退款金额',
|
||||
extra2Value: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const chartTabs: TabOption[] = [
|
||||
{
|
||||
label: '订单趋势',
|
||||
value: 'visits',
|
||||
},
|
||||
{
|
||||
label: '推广访问趋势',
|
||||
value: 'trends',
|
||||
},
|
||||
];
|
||||
|
||||
// 获取统计数据
|
||||
async function fetchStatistics() {
|
||||
try {
|
||||
// 获取今日的开始和结束时间
|
||||
const today = new Date();
|
||||
// 将时间格式化为后端期望的格式 (YYYY-MM-DD HH:MM:SS)
|
||||
const startTime = new Date(today.getFullYear(), today.getMonth(), today.getDate()).toISOString().replace('T', ' ').substring(0, 19);
|
||||
const endTime = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1).toISOString().replace('T', ' ').substring(0, 19);
|
||||
|
||||
// 获取平台用户数据(总数)
|
||||
const platformUserResponse = await getPlatformUserList({ page: 1, pageSize: 1 });
|
||||
const platformUserTotal = platformUserResponse.total || 0;
|
||||
|
||||
// 获取今日新增用户数
|
||||
// 由于平台用户API不支持时间过滤,我们需要获取更多数据并在前端过滤
|
||||
const newUserResponse = await getPlatformUserList({ page: 1, pageSize: 1000 });
|
||||
const newUserCount = newUserResponse.items?.filter(user => {
|
||||
const userCreateTime = new Date(user.create_time);
|
||||
return userCreateTime >= new Date(startTime) && userCreateTime < new Date(endTime);
|
||||
}).length || 0;
|
||||
|
||||
// 获取订单数据
|
||||
const orderResponse = await getOrderList({ page: 1, pageSize: 1 });
|
||||
const orderTotal = orderResponse.total || 0;
|
||||
|
||||
// 获取代理订单数据
|
||||
const agentOrderResponse = await getAgentOrderStatistics();
|
||||
const agentOrderTotal = agentOrderResponse.total_agent_order_count || 0;
|
||||
|
||||
// 获取今日新增订单数
|
||||
const todayOrderResponse = await getOrderList({
|
||||
page: 1,
|
||||
pageSize: 1000,
|
||||
create_time_start: startTime,
|
||||
create_time_end: endTime
|
||||
});
|
||||
const todayOrderTotal = todayOrderResponse.total || 0;
|
||||
|
||||
// 获取今日新增代理订单数
|
||||
const todayAgentOrderResponse = await getAgentOrderStatistics();
|
||||
const todayAgentOrderTotal = todayAgentOrderResponse.today_agent_order_count || 0;
|
||||
|
||||
// Product data is no longer needed for order statistics
|
||||
|
||||
// 获取代理统计数据
|
||||
const agentStatsResponse = await getAgentStatistics();
|
||||
const agentTotal = agentStatsResponse.total_agent_count || 0;
|
||||
const newAgentCount = agentStatsResponse.today_agent_count || 0;
|
||||
|
||||
// 获取提现统计数据
|
||||
const withdrawalStatsResponse = await getWithdrawalStatistics();
|
||||
const totalWithdrawalAmount = withdrawalStatsResponse.total_withdrawal_amount || 0;
|
||||
const todayWithdrawalAmount = withdrawalStatsResponse.today_withdrawal_amount || 0;
|
||||
const totalActualAmount = withdrawalStatsResponse.total_actual_amount || 0;
|
||||
const totalTaxAmount = withdrawalStatsResponse.total_tax_amount || 0;
|
||||
|
||||
// 获取退款统计数据
|
||||
const refundStatsResponse = await getRefundStatistics();
|
||||
const totalRefundAmount = refundStatsResponse.total_refund_amount || 0;
|
||||
const todayRefundAmount = refundStatsResponse.today_refund_amount || 0;
|
||||
|
||||
// 获取收入统计数据
|
||||
const incomeStatsResponse = await getIncomeStatistics();
|
||||
const totalIncome = incomeStatsResponse.total_revenue_amount || 0;
|
||||
const todayIncome = incomeStatsResponse.today_revenue_amount || 0;
|
||||
const totalProfit = incomeStatsResponse.total_profit_amount || 0;
|
||||
const todayProfit = incomeStatsResponse.today_profit_amount || 0;
|
||||
|
||||
// 更新概览数据
|
||||
overviewItems.value = [
|
||||
{
|
||||
icon: SvgCardIcon,
|
||||
title: '总用户数',
|
||||
value: platformUserTotal,
|
||||
todaytitle: '今日新增用户数',
|
||||
todayValue: newUserCount,
|
||||
Subtitle: '总代理数',
|
||||
SubValue: agentTotal,
|
||||
todaySubtitle: '今日新增代理数',
|
||||
todaySubValue: newAgentCount,
|
||||
},
|
||||
{
|
||||
icon: SvgCakeIcon,
|
||||
title: '总订单数',
|
||||
value: orderTotal,
|
||||
todaytitle: '今日新增订单数',
|
||||
todayValue: todayOrderTotal,
|
||||
Subtitle: '总代理订单量',
|
||||
SubValue: agentOrderTotal,
|
||||
todaySubtitle: '今日新增代理订单量',
|
||||
todaySubValue: todayAgentOrderTotal,
|
||||
},
|
||||
{
|
||||
icon: SvgDownloadIcon,
|
||||
title: '总收入流水金额',
|
||||
value: totalIncome,
|
||||
todaytitle: '今日新增收入流水金额',
|
||||
todayValue: todayIncome,
|
||||
Subtitle: '总利润',
|
||||
SubValue: totalProfit,
|
||||
todaySubtitle: '今日新增利润',
|
||||
todaySubValue: todayProfit,
|
||||
},
|
||||
{
|
||||
icon: SvgBellIcon,
|
||||
title: '总提现金额',
|
||||
value: totalWithdrawalAmount,
|
||||
todaytitle: '今日新增提现金额',
|
||||
todayValue: todayWithdrawalAmount,
|
||||
extra2Title: '今日新增退款金额',
|
||||
extra2Value: todayRefundAmount,
|
||||
Subtitle: '总退款金额',
|
||||
SubValue: totalRefundAmount,
|
||||
todaySubtitle: '总实际到账金额',
|
||||
todaySubValue: totalActualAmount,
|
||||
extraTitle: '总扣税金额',
|
||||
extraValue: totalTaxAmount,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchStatistics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<AnalysisOverview :items="overviewItems" />
|
||||
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
|
||||
<template #visits>
|
||||
<AnalyticsVisits />
|
||||
</template>
|
||||
<template #trends>
|
||||
<AnalyticsTrends />
|
||||
</template>
|
||||
</AnalysisChartsTabs>
|
||||
|
||||
<div class="mt-5 w-full md:flex">
|
||||
<AnalysisChartCard
|
||||
class="mt-5 md:mr-4 md:mt-0 md:w-1/3"
|
||||
title="推广数据分析"
|
||||
>
|
||||
<AnalyticsVisitsData />
|
||||
</AnalysisChartCard>
|
||||
<AnalysisChartCard
|
||||
class="mt-5 md:mr-4 md:mt-0 md:w-1/3"
|
||||
title="订单来源分析"
|
||||
>
|
||||
<AnalyticsVisitsSource />
|
||||
</AnalysisChartCard>
|
||||
<AnalysisChartCard
|
||||
class="mt-5 md:mt-0 md:w-1/3"
|
||||
title="佣金/奖励/提现统计"
|
||||
>
|
||||
<AnalyticsVisitsSales />
|
||||
</AnalysisChartCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
266
apps/web-antd/src/views/dashboard/workspace/index.vue
Normal file
266
apps/web-antd/src/views/dashboard/workspace/index.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
WorkbenchProjectItem,
|
||||
WorkbenchQuickNavItem,
|
||||
WorkbenchTodoItem,
|
||||
WorkbenchTrendItem,
|
||||
} from '@vben/common-ui';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import {
|
||||
AnalysisChartCard,
|
||||
WorkbenchHeader,
|
||||
WorkbenchProject,
|
||||
WorkbenchQuickNav,
|
||||
WorkbenchTodo,
|
||||
WorkbenchTrends,
|
||||
} from '@vben/common-ui';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import AnalyticsVisitsSource from '../analytics/analytics-visits-source.vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 这是一个示例数据,实际项目中需要根据实际情况进行调整
|
||||
// url 也可以是内部路由,在 navTo 方法中识别处理,进行内部跳转
|
||||
// 例如:url: /dashboard/workspace
|
||||
const projectItems: WorkbenchProjectItem[] = [
|
||||
{
|
||||
color: '',
|
||||
content: '不要等待机会,而要创造机会。',
|
||||
date: '2021-04-01',
|
||||
group: '开源组',
|
||||
icon: 'carbon:logo-github',
|
||||
title: 'Github',
|
||||
url: 'https://github.com',
|
||||
},
|
||||
{
|
||||
color: '#3fb27f',
|
||||
content: '现在的你决定将来的你。',
|
||||
date: '2021-04-01',
|
||||
group: '算法组',
|
||||
icon: 'ion:logo-vue',
|
||||
title: 'Vue',
|
||||
url: 'https://vuejs.org',
|
||||
},
|
||||
{
|
||||
color: '#e18525',
|
||||
content: '没有什么才能比努力更重要。',
|
||||
date: '2021-04-01',
|
||||
group: '上班摸鱼',
|
||||
icon: 'ion:logo-html5',
|
||||
title: 'Html5',
|
||||
url: 'https://developer.mozilla.org/zh-CN/docs/Web/HTML',
|
||||
},
|
||||
{
|
||||
color: '#bf0c2c',
|
||||
content: '热情和欲望可以突破一切难关。',
|
||||
date: '2021-04-01',
|
||||
group: 'UI',
|
||||
icon: 'ion:logo-angular',
|
||||
title: 'Angular',
|
||||
url: 'https://angular.io',
|
||||
},
|
||||
{
|
||||
color: '#00d8ff',
|
||||
content: '健康的身体是实现目标的基石。',
|
||||
date: '2021-04-01',
|
||||
group: '技术牛',
|
||||
icon: 'bx:bxl-react',
|
||||
title: 'React',
|
||||
url: 'https://reactjs.org',
|
||||
},
|
||||
{
|
||||
color: '#EBD94E',
|
||||
content: '路是走出来的,而不是空想出来的。',
|
||||
date: '2021-04-01',
|
||||
group: '架构组',
|
||||
icon: 'ion:logo-javascript',
|
||||
title: 'Js',
|
||||
url: 'https://developer.mozilla.org/zh-CN/docs/Web/JavaScript',
|
||||
},
|
||||
];
|
||||
|
||||
// 同样,这里的 url 也可以使用以 http 开头的外部链接
|
||||
const quickNavItems: WorkbenchQuickNavItem[] = [
|
||||
{
|
||||
color: '#1fdaca',
|
||||
icon: 'ion:home-outline',
|
||||
title: '首页',
|
||||
url: '/',
|
||||
},
|
||||
{
|
||||
color: '#bf0c2c',
|
||||
icon: 'ion:grid-outline',
|
||||
title: '仪表盘',
|
||||
url: '/dashboard',
|
||||
},
|
||||
{
|
||||
color: '#e18525',
|
||||
icon: 'ion:layers-outline',
|
||||
title: '组件',
|
||||
url: '/demos/features/icons',
|
||||
},
|
||||
{
|
||||
color: '#3fb27f',
|
||||
icon: 'ion:settings-outline',
|
||||
title: '系统管理',
|
||||
url: '/demos/features/login-expired', // 这里的 URL 是示例,实际项目中需要根据实际情况进行调整
|
||||
},
|
||||
{
|
||||
color: '#4daf1bc9',
|
||||
icon: 'ion:key-outline',
|
||||
title: '权限管理',
|
||||
url: '/demos/access/page-control',
|
||||
},
|
||||
{
|
||||
color: '#00d8ff',
|
||||
icon: 'ion:bar-chart-outline',
|
||||
title: '图表',
|
||||
url: '/analytics',
|
||||
},
|
||||
];
|
||||
|
||||
const todoItems = ref<WorkbenchTodoItem[]>([
|
||||
{
|
||||
completed: false,
|
||||
content: `审查最近提交到Git仓库的前端代码,确保代码质量和规范。`,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '审查前端代码提交',
|
||||
},
|
||||
{
|
||||
completed: true,
|
||||
content: `检查并优化系统性能,降低CPU使用率。`,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '系统性能优化',
|
||||
},
|
||||
{
|
||||
completed: false,
|
||||
content: `进行系统安全检查,确保没有安全漏洞或未授权的访问。 `,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '安全检查',
|
||||
},
|
||||
{
|
||||
completed: false,
|
||||
content: `更新项目中的所有npm依赖包,确保使用最新版本。`,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '更新项目依赖',
|
||||
},
|
||||
{
|
||||
completed: false,
|
||||
content: `修复用户报告的页面UI显示问题,确保在不同浏览器中显示一致。 `,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '修复UI显示问题',
|
||||
},
|
||||
]);
|
||||
const trendItems: WorkbenchTrendItem[] = [
|
||||
{
|
||||
avatar: 'svg:avatar-1',
|
||||
content: `在 <a>开源组</a> 创建了项目 <a>Vue</a>`,
|
||||
date: '刚刚',
|
||||
title: '威廉',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-2',
|
||||
content: `关注了 <a>威廉</a> `,
|
||||
date: '1个小时前',
|
||||
title: '艾文',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-3',
|
||||
content: `发布了 <a>个人动态</a> `,
|
||||
date: '1天前',
|
||||
title: '克里斯',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-4',
|
||||
content: `发表文章 <a>如何编写一个Vite插件</a> `,
|
||||
date: '2天前',
|
||||
title: 'Vben',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-1',
|
||||
content: `回复了 <a>杰克</a> 的问题 <a>如何进行项目优化?</a>`,
|
||||
date: '3天前',
|
||||
title: '皮特',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-2',
|
||||
content: `关闭了问题 <a>如何运行项目</a> `,
|
||||
date: '1周前',
|
||||
title: '杰克',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-3',
|
||||
content: `发布了 <a>个人动态</a> `,
|
||||
date: '1周前',
|
||||
title: '威廉',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-4',
|
||||
content: `推送了代码到 <a>Github</a>`,
|
||||
date: '2021-04-01 20:00',
|
||||
title: '威廉',
|
||||
},
|
||||
{
|
||||
avatar: 'svg:avatar-4',
|
||||
content: `发表文章 <a>如何编写使用 Admin Vben</a> `,
|
||||
date: '2021-03-01 20:00',
|
||||
title: 'Vben',
|
||||
},
|
||||
];
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// 这是一个示例方法,实际项目中需要根据实际情况进行调整
|
||||
// This is a sample method, adjust according to the actual project requirements
|
||||
function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
if (nav.url?.startsWith('http')) {
|
||||
openWindow(nav.url);
|
||||
return;
|
||||
}
|
||||
if (nav.url?.startsWith('/')) {
|
||||
router.push(nav.url).catch((error) => {
|
||||
console.error('Navigation failed:', error);
|
||||
});
|
||||
} else {
|
||||
console.warn(`Unknown URL for navigation item: ${nav.title} -> ${nav.url}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<WorkbenchHeader
|
||||
:avatar="userStore.userInfo?.avatar || preferences.app.defaultAvatar"
|
||||
>
|
||||
<template #title>
|
||||
早安, {{ userStore.userInfo?.realName }}, 开始您一天的工作吧!
|
||||
</template>
|
||||
<template #description> 今日晴,20℃ - 32℃! </template>
|
||||
</WorkbenchHeader>
|
||||
|
||||
<div class="mt-5 flex flex-col lg:flex-row">
|
||||
<div class="mr-4 w-full lg:w-3/5">
|
||||
<WorkbenchProject :items="projectItems" title="项目" @click="navTo" />
|
||||
<WorkbenchTrends :items="trendItems" class="mt-5" title="最新动态" />
|
||||
</div>
|
||||
<div class="w-full lg:w-2/5">
|
||||
<WorkbenchQuickNav
|
||||
:items="quickNavItems"
|
||||
class="mt-5 lg:mt-0"
|
||||
title="快捷导航"
|
||||
@click="navTo"
|
||||
/>
|
||||
<WorkbenchTodo :items="todoItems" class="mt-5" title="待办事项" />
|
||||
<AnalysisChartCard class="mt-5" title="访问来源">
|
||||
<AnalyticsVisitsSource />
|
||||
</AnalysisChartCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
175
apps/web-antd/src/views/notification/data.ts
Normal file
175
apps/web-antd/src/views/notification/data.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { NotificationApi } from '#/api/notification';
|
||||
|
||||
// 表单配置
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'title',
|
||||
label: '通知标题',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'notification_page',
|
||||
label: '通知页面',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'RichText',
|
||||
fieldName: 'content',
|
||||
label: '通知内容',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'show_date',
|
||||
label: '展示日期',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'show_time',
|
||||
label: '展示时间',
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
],
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 搜索表单配置
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'title',
|
||||
label: '通知标题',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'notification_page',
|
||||
label: '通知页面',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: '首页', value: 'home' },
|
||||
{ label: '个人中心', value: 'profile' },
|
||||
{ label: '订单页', value: 'order' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: '启用', value: 'active' },
|
||||
{ label: '禁用', value: 'inactive' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'RangePicker',
|
||||
fieldName: 'date_range',
|
||||
label: '生效时间',
|
||||
componentProps: {
|
||||
showTime: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
export function useColumns<T = NotificationApi.NotificationItem>(
|
||||
onActionClick: OnActionClickFn<T>,
|
||||
onStatusChange?: (
|
||||
newStatus: 0 | 1,
|
||||
row: T,
|
||||
) => PromiseLike<boolean | undefined>,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'title',
|
||||
title: '通知标题',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'notification_page',
|
||||
title: '通知页面',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
field: 'show_date',
|
||||
title: '展示日期',
|
||||
width: 300,
|
||||
formatter: ({ row }) => {
|
||||
return !row.start_date && !row.end_date
|
||||
? '每天'
|
||||
: `${row.start_date} 至 ${row.end_date}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'show_time',
|
||||
title: '展示时间',
|
||||
width: 300,
|
||||
formatter: ({ row }) => {
|
||||
return (!row.start_time && !row.end_time) ||
|
||||
row.start_time === row.end_time
|
||||
? '全天'
|
||||
: `${row.start_time} 至 ${row.end_time}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
cellRender: {
|
||||
attrs: {
|
||||
beforeChange: onStatusChange,
|
||||
},
|
||||
name: onStatusChange ? 'CellSwitch' : 'CellTag',
|
||||
},
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
field: 'update_time',
|
||||
title: '更新时间',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'title',
|
||||
nameTitle: '通知',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
},
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
title: '操作',
|
||||
width: 130,
|
||||
},
|
||||
];
|
||||
}
|
||||
204
apps/web-antd/src/views/notification/list.vue
Normal file
204
apps/web-antd/src/views/notification/list.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { NotificationApi } from '#/api/notification';
|
||||
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
import { Plus } from '@vben/icons';
|
||||
|
||||
import { Button, message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
deleteNotification,
|
||||
getNotificationList,
|
||||
updateNotification,
|
||||
} from '#/api/notification';
|
||||
|
||||
import { useColumns, useGridFormSchema } from './data';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
// 表单抽屉
|
||||
const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 表格配置
|
||||
const columns = useColumns<NotificationApi.NotificationItem>(
|
||||
onActionClick,
|
||||
(newStatus: 0 | 1, row: NotificationApi.NotificationItem) => {
|
||||
return onStatusChange(newStatus, row);
|
||||
},
|
||||
);
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
fieldMappingTime: [['date', ['startDate', 'endDate']]],
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns,
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getNotificationList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
} as VxeTableGridOptions<NotificationApi.NotificationItem>,
|
||||
});
|
||||
|
||||
// 操作处理函数
|
||||
function onActionClick(
|
||||
e: OnActionClickParams<NotificationApi.NotificationItem>,
|
||||
) {
|
||||
switch (e.code) {
|
||||
case 'delete': {
|
||||
onDelete(e.row);
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
onEdit(e.row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Antd的Modal.confirm封装为promise,方便在异步函数中调用。
|
||||
* @param content 提示内容
|
||||
* @param title 提示标题
|
||||
*/
|
||||
function confirm(content: string, title: string) {
|
||||
return new Promise((reslove, reject) => {
|
||||
Modal.confirm({
|
||||
content,
|
||||
onCancel() {
|
||||
reject(new Error('已取消'));
|
||||
},
|
||||
onOk() {
|
||||
reslove(true);
|
||||
},
|
||||
title,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态开关即将改变
|
||||
* @param newStatus 期望改变的状态值
|
||||
* @param row 行数据
|
||||
* @returns 返回false则中止改变,返回其他值(undefined、true)则允许改变
|
||||
*/
|
||||
async function onStatusChange(
|
||||
newStatus: 0 | 1,
|
||||
row: NotificationApi.NotificationItem,
|
||||
) {
|
||||
const status: Recordable<string> = {
|
||||
1: '启用',
|
||||
0: '禁用',
|
||||
};
|
||||
try {
|
||||
await confirm(
|
||||
`你要将通知"${row.title}"的状态切换为【${status[newStatus]}】吗?`,
|
||||
'切换状态',
|
||||
);
|
||||
// 获取完整的通知数据
|
||||
const notification = await getNotificationList({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
id: row.id,
|
||||
});
|
||||
const fullData = notification.items[0];
|
||||
if (!fullData) {
|
||||
message.error('获取通知数据失败');
|
||||
return false;
|
||||
}
|
||||
await updateNotification(row.id, {
|
||||
id: row.id,
|
||||
status: newStatus,
|
||||
title: fullData.title,
|
||||
content: fullData.content || '',
|
||||
notification_page: fullData.notification_page,
|
||||
start_date: fullData.start_date,
|
||||
start_time: fullData.start_time,
|
||||
end_date: fullData.end_date,
|
||||
end_time: fullData.end_time,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑处理
|
||||
function onEdit(row: NotificationApi.NotificationItem) {
|
||||
formDrawerApi.setData(row).open();
|
||||
}
|
||||
|
||||
// 删除处理
|
||||
function onDelete(row: NotificationApi.NotificationItem) {
|
||||
const hideLoading = message.loading({
|
||||
content: `正在删除通知:${row.title}`,
|
||||
duration: 0,
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
deleteNotification(row.id)
|
||||
.then(() => {
|
||||
message.success({
|
||||
content: `删除通知成功:${row.title}`,
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
onRefresh();
|
||||
})
|
||||
.catch(() => {
|
||||
hideLoading();
|
||||
});
|
||||
}
|
||||
|
||||
// 刷新处理
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
// 创建处理
|
||||
function onCreate() {
|
||||
formDrawerApi.setData({}).open();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormDrawer @success="onRefresh" />
|
||||
<Grid table-title="通知列表">
|
||||
<template #toolbar-tools>
|
||||
<Button type="primary" @click="onCreate">
|
||||
<Plus class="size-5" />
|
||||
创建通知
|
||||
</Button>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
270
apps/web-antd/src/views/notification/modules/form.vue
Normal file
270
apps/web-antd/src/views/notification/modules/form.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import type { NotificationApi } from '#/api/notification';
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { useVbenDrawer, useVbenForm } from '@vben/common-ui';
|
||||
|
||||
import { DatePicker, RadioButton, RadioGroup } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { createNotification, updateNotification } from '#/api/notification';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'success'): void;
|
||||
}>();
|
||||
|
||||
// 使用单选按钮组来控制模式
|
||||
const dateMode = ref<'daily' | 'range'>('range');
|
||||
const timeMode = ref<'allDay' | 'range'>('range');
|
||||
|
||||
// 表单值管理
|
||||
const formValues = ref<{
|
||||
[key: string]: any;
|
||||
dateRange?: [Dayjs, Dayjs] | undefined;
|
||||
timeRange?: [Dayjs, Dayjs] | undefined;
|
||||
}>({});
|
||||
|
||||
const id = ref<number>();
|
||||
|
||||
// 表单配置
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
// 抽屉配置
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
class: 'w-[800px]',
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
|
||||
// 获取表单值
|
||||
const values = await formApi.getValues();
|
||||
|
||||
// 处理日期时间值
|
||||
if (dateMode.value === 'daily') {
|
||||
values.start_date = '';
|
||||
values.end_date = '';
|
||||
} else if (formValues.value.dateRange) {
|
||||
const [start, end] = formValues.value.dateRange;
|
||||
values.start_date = start.format('YYYY-MM-DD');
|
||||
values.end_date = end.format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
if (timeMode.value === 'allDay') {
|
||||
values.start_time = '';
|
||||
values.end_time = '';
|
||||
} else if (formValues.value.timeRange) {
|
||||
const [start, end] = formValues.value.timeRange;
|
||||
values.start_time = start.format('HH:mm:ss');
|
||||
values.end_time = end.format('HH:mm:ss');
|
||||
}
|
||||
|
||||
// 提交数据
|
||||
drawerApi.lock();
|
||||
try {
|
||||
await (id.value
|
||||
? updateNotification(id.value, {
|
||||
...values,
|
||||
} as NotificationApi.UpdateNotificationRequest)
|
||||
: createNotification(
|
||||
values as NotificationApi.CreateNotificationRequest,
|
||||
));
|
||||
emit('success');
|
||||
drawerApi.close();
|
||||
} catch {
|
||||
drawerApi.unlock();
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<NotificationApi.NotificationItem>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
id.value = data.id;
|
||||
|
||||
// 初始化日期时间范围
|
||||
const dateRange =
|
||||
data.start_date && data.end_date
|
||||
? ([dayjs(data.start_date), dayjs(data.end_date)] as [Dayjs, Dayjs])
|
||||
: undefined;
|
||||
const timeRange =
|
||||
data.start_time && data.end_time
|
||||
? ([
|
||||
dayjs(data.start_time, 'HH:mm:ss'),
|
||||
dayjs(data.end_time, 'HH:mm:ss'),
|
||||
] as [Dayjs, Dayjs])
|
||||
: undefined;
|
||||
|
||||
// 设置表单值
|
||||
formValues.value = {
|
||||
...data,
|
||||
dateRange,
|
||||
timeRange,
|
||||
};
|
||||
nextTick(() => {
|
||||
// 设置表单字段值
|
||||
formApi.setValues({
|
||||
...data,
|
||||
start_date: data.start_date || undefined,
|
||||
end_date: data.end_date || undefined,
|
||||
start_time: data.start_time || undefined,
|
||||
end_time: data.end_time || undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// 设置模式
|
||||
dateMode.value = !data.start_date && !data.end_date ? 'daily' : 'range';
|
||||
timeMode.value =
|
||||
(!data.start_time && !data.end_time) ||
|
||||
data.start_time === data.end_time
|
||||
? 'allDay'
|
||||
: 'range';
|
||||
} else {
|
||||
// 重置所有值
|
||||
id.value = undefined;
|
||||
formValues.value = {
|
||||
dateRange: undefined,
|
||||
timeRange: undefined,
|
||||
};
|
||||
dateMode.value = 'range';
|
||||
timeMode.value = 'range';
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 计算抽屉标题
|
||||
const getDrawerTitle = computed(() => {
|
||||
return id.value ? '编辑通知' : '创建通知';
|
||||
});
|
||||
|
||||
// 监听模式变化
|
||||
watch(dateMode, (newMode) => {
|
||||
if (newMode === 'daily') {
|
||||
formValues.value.dateRange = undefined;
|
||||
formApi.setFieldValue('start_date', '');
|
||||
formApi.setFieldValue('end_date', '');
|
||||
}
|
||||
});
|
||||
|
||||
watch(timeMode, (newMode) => {
|
||||
if (newMode === 'allDay') {
|
||||
formValues.value.timeRange = undefined;
|
||||
formApi.setFieldValue('start_time', '');
|
||||
formApi.setFieldValue('end_time', '');
|
||||
}
|
||||
});
|
||||
|
||||
// 更新字段值
|
||||
const updateField = (
|
||||
field: string,
|
||||
value: [Dayjs, Dayjs] | null | undefined,
|
||||
) => {
|
||||
if (!value) {
|
||||
if (field === 'dateRange') {
|
||||
formValues.value.dateRange = undefined;
|
||||
formApi.setFieldValue('start_date', '');
|
||||
formApi.setFieldValue('end_date', '');
|
||||
} else if (field === 'timeRange') {
|
||||
formValues.value.timeRange = undefined;
|
||||
formApi.setFieldValue('start_time', '');
|
||||
formApi.setFieldValue('end_time', '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const [start, end] = value;
|
||||
if (!start || !end) return;
|
||||
|
||||
if (field === 'dateRange') {
|
||||
formValues.value.dateRange = [start, end];
|
||||
formApi.setFieldValue('start_date', start.format('YYYY-MM-DD'));
|
||||
formApi.setFieldValue('end_date', end.format('YYYY-MM-DD'));
|
||||
} else if (field === 'timeRange') {
|
||||
formValues.value.timeRange = [start, end];
|
||||
formApi.setFieldValue('start_time', start.format('HH:mm:ss'));
|
||||
formApi.setFieldValue('end_time', end.format('HH:mm:ss'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer :title="getDrawerTitle">
|
||||
<Form>
|
||||
<template #show_date>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup v-model:value="dateMode" button-style="solid">
|
||||
<RadioButton value="range">日期范围</RadioButton>
|
||||
<RadioButton value="daily">每天</RadioButton>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="dateMode === 'range'" class="form-item">
|
||||
<label class="form-label">日期范围</label>
|
||||
<DatePicker.RangePicker
|
||||
:value="formValues.dateRange"
|
||||
@update:value="
|
||||
(val) => updateField('dateRange', val as [Dayjs, Dayjs] | null)
|
||||
"
|
||||
format="YYYY-MM-DD"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #show_time>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup v-model:value="timeMode" button-style="solid">
|
||||
<RadioButton value="range">时间段</RadioButton>
|
||||
<RadioButton value="allDay">全天</RadioButton>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="timeMode === 'range'" class="form-item">
|
||||
<label class="form-label">时间范围</label>
|
||||
<DatePicker.RangePicker
|
||||
:value="formValues.timeRange"
|
||||
@update:value="
|
||||
(val) => updateField('timeRange', val as [Dayjs, Dayjs] | null)
|
||||
"
|
||||
format="HH:mm:ss"
|
||||
picker="time"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
color: rgb(0 0 0 / 85%);
|
||||
}
|
||||
|
||||
:deep(.ant-radio-group) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-picker) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
266
apps/web-antd/src/views/order/order/data.ts
Normal file
266
apps/web-antd/src/views/order/order/data.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { OrderApi } from '#/api/order';
|
||||
|
||||
export function useColumns<T = OrderApi.Order>(
|
||||
onActionClick: OnActionClickFn<T>,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'id',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'order_no',
|
||||
title: '商户订单号',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: 'platform_order_id',
|
||||
title: '支付订单号',
|
||||
minWidth: 220,
|
||||
},
|
||||
{
|
||||
field: 'product_name',
|
||||
title: '产品',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'payment_platform',
|
||||
title: '支付方式',
|
||||
width: 120,
|
||||
formatter: ({ row }) => {
|
||||
const platformMap: Record<string, string> = {
|
||||
alipay: '支付宝',
|
||||
wechat: '微信支付',
|
||||
appleiap: '苹果支付',
|
||||
};
|
||||
return platformMap[row.payment_platform] || row.payment_platform;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'payment_scene',
|
||||
title: '支付平台',
|
||||
width: 120,
|
||||
formatter: ({ row }) => {
|
||||
const sceneMap: Record<string, string> = {
|
||||
app: 'APP',
|
||||
h5: 'H5',
|
||||
mini_program: '小程序',
|
||||
public_account: '公众号',
|
||||
};
|
||||
return sceneMap[row.payment_scene] || row.payment_scene;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'sales_cost',
|
||||
title: '成本价',
|
||||
width: 120,
|
||||
formatter: ({ row }) => {
|
||||
return `¥${row.sales_cost.toFixed(2)}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'amount',
|
||||
title: '金额',
|
||||
width: 120,
|
||||
formatter: ({ row }) => {
|
||||
return `¥${row.amount.toFixed(2)}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
options: [
|
||||
{ value: 'pending', color: 'warning', label: '待支付' },
|
||||
{ value: 'paid', color: 'success', label: '已支付' },
|
||||
{ value: 'failed', color: 'error', label: '支付失败' },
|
||||
{ value: 'refunded', color: 'purple', label: '已退款' },
|
||||
{ value: 'refunding', color: 'pink', label: '退款中' },
|
||||
{ value: 'closed', color: 'default', label: '已关闭' },
|
||||
],
|
||||
},
|
||||
field: 'status',
|
||||
title: '支付状态',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
options: [
|
||||
{ value: 'pending', color: 'warning', label: '查询中' },
|
||||
{ value: 'success', color: 'success', label: '查询成功' },
|
||||
{ value: 'failed', color: 'error', label: '查询失败' },
|
||||
{ value: 'processing', color: 'warning', label: '查询中' },
|
||||
{ value: 'cleaned', color: 'default', label: '已清除结果' },
|
||||
],
|
||||
},
|
||||
field: 'query_state',
|
||||
title: '查询状态',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
field: 'pay_time',
|
||||
title: '支付时间',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
field: 'refund_time',
|
||||
title: '退款时间',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
options: [
|
||||
{ value: 0, color: 'default', label: '否' },
|
||||
{ value: 1, color: 'success', label: '是' },
|
||||
],
|
||||
},
|
||||
field: 'is_promotion',
|
||||
title: '推广订单',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'order_no',
|
||||
nameTitle: '商户订单号',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'refund',
|
||||
text: '退款',
|
||||
disabled: (row: OrderApi.Order) => {
|
||||
return row.status !== 'paid';
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'query',
|
||||
text: '查询结果',
|
||||
disabled: (row: OrderApi.Order) => {
|
||||
return row.query_state !== 'success';
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
title: '操作',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'order_no',
|
||||
label: '商户订单号',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'platform_order_id',
|
||||
label: '支付订单号',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'query_name',
|
||||
label: '被查询人姓名',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'query_id_card',
|
||||
label: '被查询人身份证',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'query_mobile',
|
||||
label: '被查询人手机号',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'product_name',
|
||||
label: '产品名称',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: '支付宝', value: 'alipay' },
|
||||
{ label: '微信支付', value: 'wechat' },
|
||||
{ label: '苹果支付', value: 'appleiap' },
|
||||
],
|
||||
},
|
||||
fieldName: 'payment_platform',
|
||||
label: '支付方式',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: 'APP', value: 'app' },
|
||||
{ label: 'H5', value: 'h5' },
|
||||
{ label: '小程序', value: 'mini_program' },
|
||||
{ label: '公众号', value: 'public_account' },
|
||||
],
|
||||
},
|
||||
fieldName: 'payment_scene',
|
||||
label: '支付平台',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: '待支付', value: 'pending' },
|
||||
{ label: '已支付', value: 'paid' },
|
||||
{ label: '支付失败', value: 'failed' },
|
||||
{ label: '已退款', value: 'refunded' },
|
||||
{ label: '已关闭', value: 'closed' },
|
||||
],
|
||||
},
|
||||
fieldName: 'status',
|
||||
label: '支付状态',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: '否', value: 0 },
|
||||
{ label: '是', value: 1 },
|
||||
],
|
||||
},
|
||||
fieldName: 'is_promotion',
|
||||
label: '推广订单',
|
||||
},
|
||||
{
|
||||
component: 'RangePicker',
|
||||
fieldName: 'create_time',
|
||||
label: '创建时间',
|
||||
},
|
||||
{
|
||||
component: 'RangePicker',
|
||||
fieldName: 'pay_time',
|
||||
label: '支付时间',
|
||||
},
|
||||
{
|
||||
component: 'RangePicker',
|
||||
fieldName: 'refund_time',
|
||||
label: '退款时间',
|
||||
},
|
||||
];
|
||||
}
|
||||
99
apps/web-antd/src/views/order/order/index.vue
Normal file
99
apps/web-antd/src/views/order/order/index.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { OrderApi } from '#/api/order';
|
||||
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getOrderList } from '#/api/order';
|
||||
|
||||
import { useColumns, useGridFormSchema } from './data';
|
||||
import RefundForm from './modules/refund-form.vue';
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
fieldMappingTime: [
|
||||
['create_time', ['create_time_start', 'create_time_end']],
|
||||
['pay_time', ['pay_time_start', 'pay_time_end']],
|
||||
['refund_time', ['refund_time_start', 'refund_time_end']],
|
||||
],
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useColumns(onActionClick),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getOrderList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: true,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
} as VxeTableGridOptions<OrderApi.Order>,
|
||||
});
|
||||
|
||||
const [RefundDrawer, refundDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: RefundForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function onActionClick(e: OnActionClickParams<OrderApi.Order>) {
|
||||
switch (e.code) {
|
||||
case 'query': {
|
||||
router.push({
|
||||
name: 'OrderQueryDetail',
|
||||
params: {
|
||||
id: e.row.id,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'refund': {
|
||||
onRefund(e.row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onRefund(row: OrderApi.Order) {
|
||||
refundDrawerApi.setData(row).open();
|
||||
}
|
||||
|
||||
function onRefundSuccess() {
|
||||
onRefresh();
|
||||
}
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<RefundDrawer @success="onRefundSuccess" />
|
||||
<Grid table-title="订单列表" />
|
||||
</Page>
|
||||
</template>
|
||||
171
apps/web-antd/src/views/order/order/modules/refund-form.vue
Normal file
171
apps/web-antd/src/views/order/order/modules/refund-form.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<script lang="ts" setup>
|
||||
import type { OrderApi } from '#/api/order';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm, z } from '#/adapter/form';
|
||||
import { refundOrder } from '#/api/order';
|
||||
|
||||
const emits = defineEmits(['success']);
|
||||
|
||||
const formData = ref<OrderApi.Order>();
|
||||
const loading = ref(false);
|
||||
const refundResult = ref<OrderApi.RefundOrderResponse>();
|
||||
|
||||
// 预设退款原因选项
|
||||
const refundReasonOptions = [
|
||||
{ label: '用户不满意要求退款', value: '用户不满意要求退款' },
|
||||
{ label: '产品问题退款', value: '产品问题退款' },
|
||||
{ label: '重复下单退款', value: '重复下单退款' },
|
||||
{ label: '其他原因', value: 'other' },
|
||||
];
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: [
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
max: computed(() => formData.value?.amount || 0),
|
||||
precision: 2,
|
||||
style: { width: '100%' },
|
||||
addonBefore: '¥',
|
||||
},
|
||||
fieldName: 'refund_amount',
|
||||
label: '退款金额',
|
||||
rules: z
|
||||
.number()
|
||||
.min(0.01, '退款金额必须大于0')
|
||||
.refine(
|
||||
(val: number) => val <= (formData.value?.amount || 0),
|
||||
'退款金额不能大于订单金额',
|
||||
),
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
max: computed(() => formData.value?.amount || 0),
|
||||
precision: 2,
|
||||
style: { width: '100%' },
|
||||
addonBefore: '¥',
|
||||
},
|
||||
fieldName: 'confirm_refund_amount',
|
||||
label: '确认退款金额',
|
||||
rules: z.number().refine(async (val: number) => {
|
||||
const form = await formApi.getValues();
|
||||
return val === (form as { refund_amount: number }).refund_amount;
|
||||
}, '两次输入的退款金额不一致'),
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: refundReasonOptions,
|
||||
style: { width: '100%' },
|
||||
},
|
||||
fieldName: 'preset_reason',
|
||||
label: '退款原因',
|
||||
defaultValue: 'other',
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
componentProps: (values) => {
|
||||
return {
|
||||
rows: 3,
|
||||
placeholder: '请输入具体退款原因',
|
||||
maxLength: 200,
|
||||
showCount: true,
|
||||
style: { width: '100%' },
|
||||
disabled:
|
||||
(values as { preset_reason?: string })?.preset_reason !== 'other',
|
||||
};
|
||||
},
|
||||
fieldName: 'refund_reason',
|
||||
label: ' ',
|
||||
rules: z.string().superRefine(async (val, ctx) => {
|
||||
const values = await formApi.getValues();
|
||||
const presetReason = (values as { preset_reason?: string })
|
||||
?.preset_reason;
|
||||
if (presetReason === 'other') {
|
||||
if (!val || val.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '请输入具体退款原因',
|
||||
});
|
||||
} else if (val.length > 200) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '退款原因不能超过200个字符',
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
const values = await formApi.getValues<{
|
||||
preset_reason: string;
|
||||
refund_amount: number;
|
||||
refund_reason: string;
|
||||
}>();
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
refundResult.value = await refundOrder(formData.value!.id, {
|
||||
refund_amount: values.refund_amount,
|
||||
refund_reason:
|
||||
values.preset_reason === 'other'
|
||||
? values.refund_reason
|
||||
: values.preset_reason,
|
||||
});
|
||||
|
||||
message.success({
|
||||
content: `退款成功!退款单号:${refundResult.value.refund_no}`,
|
||||
duration: 0,
|
||||
key: 'refund_result',
|
||||
});
|
||||
|
||||
emits('success');
|
||||
drawerApi.close();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<OrderApi.Order>();
|
||||
formApi.resetForm();
|
||||
refundResult.value = undefined;
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
// 默认填入订单金额和选择其他原因
|
||||
formApi.setValues({
|
||||
refund_amount: data.amount,
|
||||
confirm_refund_amount: data.amount,
|
||||
preset_reason: 'other',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getDrawerTitle = computed(() => {
|
||||
return `退款订单 ${formData.value?.order_no || ''}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer :title="getDrawerTitle" :loading="loading" width="600">
|
||||
<Form />
|
||||
</Drawer>
|
||||
</template>
|
||||
293
apps/web-antd/src/views/order/query-cleanup/data.ts
Normal file
293
apps/web-antd/src/views/order/query-cleanup/data.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { computed, h } from 'vue';
|
||||
|
||||
import { notification } from 'ant-design-vue';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
|
||||
export interface QueryCleanupConfigItem {
|
||||
id: number;
|
||||
config_key: string;
|
||||
config_value: string;
|
||||
config_desc: string;
|
||||
status: number;
|
||||
create_time: string;
|
||||
update_time: string;
|
||||
}
|
||||
|
||||
export interface QueryCleanupLogItem {
|
||||
id: number;
|
||||
cleanup_time: string;
|
||||
cleanup_before: string;
|
||||
status: number;
|
||||
affected_rows: number;
|
||||
error_msg: string;
|
||||
remark: string;
|
||||
create_time: string;
|
||||
}
|
||||
|
||||
export interface QueryCleanupDetailItem {
|
||||
id: number;
|
||||
cleanup_log_id: number;
|
||||
query_id: number;
|
||||
order_id: number;
|
||||
user_id: number;
|
||||
product_id: number;
|
||||
query_state: string;
|
||||
create_time_old: string;
|
||||
create_time: string;
|
||||
}
|
||||
|
||||
export function useColumns<T = QueryCleanupLogItem>(
|
||||
onActionClick?: OnActionClickFn<T>,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'id',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'cleanup_time',
|
||||
title: '清理时间',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
field: 'cleanup_before',
|
||||
title: '清理截止时间',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
options: [
|
||||
{ value: 1, color: 'success', label: '成功' },
|
||||
{ value: 2, color: 'error', label: '失败' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'affected_rows',
|
||||
title: '影响行数',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'error_msg',
|
||||
title: '错误信息',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
field: 'remark',
|
||||
title: '备注',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
attrs: {
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'view',
|
||||
text: '查看详情',
|
||||
},
|
||||
],
|
||||
},
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
title: '操作',
|
||||
width: 120,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useDetailColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'id',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'query_id',
|
||||
title: '查询ID',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'order_id',
|
||||
title: '订单ID',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'user_id',
|
||||
title: '用户ID',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'product_id',
|
||||
title: '产品ID',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'query_state',
|
||||
title: '查询结果',
|
||||
width: 120,
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
options: [
|
||||
{ value: 'pending', color: 'warning', label: '查询中' },
|
||||
{ value: 'success', color: 'success', label: '查询成功' },
|
||||
{ value: 'failed', color: 'error', label: '查询失败' },
|
||||
{ value: 'processing', color: 'warning', label: '查询中' },
|
||||
{ value: 'cleaned', color: 'default', label: '已清除结果' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'create_time_old',
|
||||
title: '原创建时间',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: '成功', value: 1 },
|
||||
{ label: '失败', value: 2 },
|
||||
],
|
||||
},
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
},
|
||||
{
|
||||
component: 'RangePicker',
|
||||
fieldName: 'create_time',
|
||||
label: '创建时间',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useConfigFormSchema(
|
||||
configList: Ref<QueryCleanupConfigItem[]>,
|
||||
): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入cron表达式,例如:0 3 * * *',
|
||||
addonAfter: h(
|
||||
'a',
|
||||
{
|
||||
class: 'cursor-pointer',
|
||||
onClick: () => {
|
||||
notification.info({
|
||||
message: 'Cron表达式说明',
|
||||
description: h('div', { class: 'space-y-2' }, [
|
||||
h('p', 'Cron表达式格式:分 时 日 月 周'),
|
||||
h('p', '常用示例:'),
|
||||
h('ul', { class: 'list-disc pl-4' }, [
|
||||
h('li', '每天凌晨3点执行:0 3 * * *'),
|
||||
h('li', '每周一凌晨3点执行:0 3 * * 1'),
|
||||
h('li', '每月1号凌晨3点执行:0 3 1 * *'),
|
||||
h('li', '每小时执行一次:0 * * * *'),
|
||||
h('li', '每30分钟执行一次:*/30 * * * *'),
|
||||
]),
|
||||
]),
|
||||
duration: 0,
|
||||
});
|
||||
},
|
||||
},
|
||||
'帮助',
|
||||
),
|
||||
},
|
||||
fieldName: 'cleanup_cron',
|
||||
label: '清理任务执行时间(cron表达式)',
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, '请输入cron表达式')
|
||||
.regex(
|
||||
/^(\*|(\d|1\d|2\d|3\d|4\d|5\d)|\*\/(\d|1\d|2\d|3\d|4\d|5\d)) (\*|(\d|1\d|2[0-3])|\*\/(\d|1\d|2[0-3])) (\*|([1-9]|1\d|2\d|3[01])|\*\/([1-9]|1\d|2\d|3[01])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/,
|
||||
'请输入正确的cron表达式格式',
|
||||
),
|
||||
defaultValue: computed(
|
||||
() =>
|
||||
configList.value.find(
|
||||
(item: QueryCleanupConfigItem) =>
|
||||
item.config_key === 'cleanup_cron',
|
||||
)?.config_value,
|
||||
),
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
min: 1,
|
||||
placeholder: '请输入数据保留天数',
|
||||
},
|
||||
fieldName: 'retention_days',
|
||||
label: '数据保留天数',
|
||||
rules: 'required',
|
||||
defaultValue: computed(
|
||||
() =>
|
||||
configList.value.find(
|
||||
(item: QueryCleanupConfigItem) =>
|
||||
item.config_key === 'retention_days',
|
||||
)?.config_value,
|
||||
),
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
min: 1,
|
||||
placeholder: '请输入每次清理的批次大小',
|
||||
},
|
||||
fieldName: 'batch_size',
|
||||
label: '每次清理的批次大小',
|
||||
rules: 'required',
|
||||
defaultValue: computed(
|
||||
() =>
|
||||
configList.value.find(
|
||||
(item: QueryCleanupConfigItem) => item.config_key === 'batch_size',
|
||||
)?.config_value,
|
||||
),
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '启用', value: '1' },
|
||||
{ label: '禁用', value: '2' },
|
||||
],
|
||||
},
|
||||
fieldName: 'enable_cleanup',
|
||||
label: '是否启用清理',
|
||||
rules: 'required',
|
||||
defaultValue: computed(
|
||||
() =>
|
||||
configList.value.find(
|
||||
(item: QueryCleanupConfigItem) =>
|
||||
item.config_key === 'enable_cleanup',
|
||||
)?.config_value,
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts" setup>
|
||||
import type { QueryCleanupDetailItem } from '../data';
|
||||
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getQueryCleanupDetailList } from '#/api/order/query';
|
||||
|
||||
import { useDetailColumns } from '../data';
|
||||
|
||||
const _emit = defineEmits(['success']);
|
||||
|
||||
// 定义数据接口
|
||||
interface ModalData {
|
||||
logId: number;
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '清理详情',
|
||||
destroyOnClose: true,
|
||||
onOpenChange: (isOpen) => {
|
||||
if (isOpen) {
|
||||
gridApi?.reload();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 详情列表配置
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: useDetailColumns(),
|
||||
height: 500,
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const { logId } = modalApi.getData<ModalData>();
|
||||
if (!logId) {
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
const result = await getQueryCleanupDetailList(logId, {
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: true,
|
||||
refresh: { code: 'query' },
|
||||
zoom: true,
|
||||
},
|
||||
} as VxeTableGridOptions<QueryCleanupDetailItem>,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const { logId } = modalApi.getData<ModalData>();
|
||||
if (logId && gridApi) {
|
||||
gridApi.reload();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[calc(100vw-200px)]">
|
||||
<div class="px-2">
|
||||
<Grid table-title="清理详情列表" />
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.query-cleanup-details-modal {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,219 @@
|
||||
<script lang="ts" setup>
|
||||
import type { QueryCleanupConfigItem, QueryCleanupLogItem } from './data';
|
||||
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { ColPage, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Card, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
getQueryCleanupConfigList,
|
||||
getQueryCleanupLogList,
|
||||
updateQueryCleanupConfig,
|
||||
} from '#/api/order/query';
|
||||
|
||||
import { useColumns, useConfigFormSchema, useGridFormSchema } from './data';
|
||||
import DetailsModal from './modules/details-modal.vue';
|
||||
|
||||
// 配置相关
|
||||
const configLoading = ref(false);
|
||||
const _submitting = ref(false);
|
||||
const configList = ref<QueryCleanupConfigItem[]>([]);
|
||||
|
||||
// 获取配置列表
|
||||
const fetchConfigList = async () => {
|
||||
try {
|
||||
configLoading.value = true;
|
||||
const { items } = await getQueryCleanupConfigList();
|
||||
if (items?.length) {
|
||||
configList.value = items;
|
||||
// 设置表单初始值
|
||||
const initialValues: Record<string, string> = {};
|
||||
for (const item of items) {
|
||||
initialValues[item.config_key] = item.config_value;
|
||||
}
|
||||
_configFormApi?.setValues(initialValues);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取配置列表失败:', error);
|
||||
message.error('获取配置列表失败');
|
||||
} finally {
|
||||
configLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const [ConfigForm, _configFormApi] = useVbenForm({
|
||||
schema: useConfigFormSchema(configList),
|
||||
handleSubmit: async (values) => {
|
||||
try {
|
||||
_submitting.value = true;
|
||||
|
||||
// 验证 cron 表达式
|
||||
const cronValue = values.cleanup_cron;
|
||||
if (cronValue) {
|
||||
const cronParts = cronValue.split(' ');
|
||||
if (cronParts.length !== 5) {
|
||||
throw new Error('Cron表达式格式错误:应为5个字段(分 时 日 月 周)');
|
||||
}
|
||||
|
||||
// 验证每个字段的值范围
|
||||
const [minute, hour, day, month, weekday] = cronParts;
|
||||
|
||||
// 验证分钟 (0-59)
|
||||
if (
|
||||
!/^(?:[*0-9]|1\d|2\d|3\d|4\d|5\d|\*\/(?:\d|1\d|2\d|3\d|4\d|5\d))$/.test(
|
||||
minute,
|
||||
)
|
||||
) {
|
||||
throw new Error('分钟字段格式错误:应为0-59之间的数字,或 */n 格式');
|
||||
}
|
||||
|
||||
// 验证小时 (0-23)
|
||||
if (!/^(?:[*0-9]|1\d|2[0-3]|\*\/(?:\d|1\d|2[0-3]))$/.test(hour)) {
|
||||
throw new Error('小时字段格式错误:应为0-23之间的数字,或 */n 格式');
|
||||
}
|
||||
|
||||
// 验证日期 (1-31)
|
||||
if (
|
||||
!/^(?:[*1-9]|1\d|2\d|3[01]|\*\/(?:[1-9]|1\d|2\d|3[01]))$/.test(day)
|
||||
) {
|
||||
throw new Error('日期字段格式错误:应为1-31之间的数字,或 */n 格式');
|
||||
}
|
||||
|
||||
// 验证月份 (1-12)
|
||||
if (!/^(?:[*1-9]|1[0-2]|\*\/(?:[1-9]|1[0-2]))$/.test(month)) {
|
||||
throw new Error('月份字段格式错误:应为1-12之间的数字,或 */n 格式');
|
||||
}
|
||||
|
||||
// 验证星期 (0-6)
|
||||
if (!/^(?:[*0-6]|\*\/[0-6])$/.test(weekday)) {
|
||||
throw new Error('星期字段格式错误:应为0-6之间的数字,或 */n 格式');
|
||||
}
|
||||
}
|
||||
|
||||
const updates = configList.value.map((item) => {
|
||||
const value = values[item.config_key];
|
||||
return {
|
||||
id: item.id,
|
||||
config_value: value?.toString() ?? '',
|
||||
status: item.status,
|
||||
};
|
||||
});
|
||||
|
||||
// 逐个更新配置
|
||||
for (const update of updates) {
|
||||
await updateQueryCleanupConfig(update);
|
||||
}
|
||||
message.success('配置更新成功');
|
||||
await fetchConfigList();
|
||||
} catch (error: any) {
|
||||
message.error('配置更新失败', error.message || '请检查输入是否正确');
|
||||
} finally {
|
||||
_submitting.value = false;
|
||||
}
|
||||
},
|
||||
resetButtonOptions: {
|
||||
show: false,
|
||||
},
|
||||
submitButtonOptions: {
|
||||
content: '修改',
|
||||
},
|
||||
});
|
||||
|
||||
// 详情弹窗
|
||||
const [DetailsModalComponent, detailsModalApi] = useVbenModal({
|
||||
connectedComponent: DetailsModal,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 日志列表配置
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
fieldMappingTime: [['create_time', ['start_time', 'end_time']]],
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: false,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useColumns((e) => {
|
||||
if (e.code === 'view' && e.row) {
|
||||
handleViewDetails(e.row);
|
||||
}
|
||||
}),
|
||||
minHeight: 600,
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const result = await getQueryCleanupLogList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: true,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
} as VxeTableGridOptions<QueryCleanupLogItem>,
|
||||
});
|
||||
|
||||
// 刷新处理
|
||||
function onRefresh() {
|
||||
gridApi?.query();
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetails = (row: QueryCleanupLogItem) => {
|
||||
if (row?.id) {
|
||||
detailsModalApi.setData({ logId: row.id }).open();
|
||||
}
|
||||
};
|
||||
|
||||
// 布局配置
|
||||
const layoutProps = reactive({
|
||||
leftWidth: 30,
|
||||
rightWidth: 70,
|
||||
leftMinWidth: 20,
|
||||
leftMaxWidth: 40,
|
||||
resizable: true,
|
||||
leftCollapsible: true,
|
||||
splitLine: false,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchConfigList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ColPage v-bind="layoutProps" auto-content-height>
|
||||
<template #left>
|
||||
<Card title="清理配置" :bordered="false" :loading="configLoading">
|
||||
<ConfigForm />
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<Card class="ml-2" title="清理日志" :bordered="false">
|
||||
<Grid table-title="清理日志列表" />
|
||||
</Card>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<DetailsModalComponent @success="onRefresh" />
|
||||
</ColPage>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
253
apps/web-antd/src/views/order/query/query-details.vue
Normal file
253
apps/web-antd/src/views/order/query/query-details.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<script lang="ts" setup>
|
||||
import type { JsonViewerAction } from '@vben/common-ui';
|
||||
|
||||
import type { OrderQueryApi } from '#/api/order/query';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { JsonViewer, Page } from '@vben/common-ui';
|
||||
import { MdiArrowLeft } from '@vben/icons';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Descriptions,
|
||||
message,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { getOrderQueryDetail } from '#/api/order/query';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const orderId = Number(route.params.id);
|
||||
const loading = ref(false);
|
||||
const queryDetail = ref<OrderQueryApi.QueryDetail>();
|
||||
|
||||
// 查询状态配置
|
||||
const queryStateConfig = [
|
||||
{ value: 'pending', color: 'warning', label: '查询中' },
|
||||
{ value: 'success', color: 'success', label: '查询成功' },
|
||||
{ value: 'failed', color: 'error', label: '查询失败' },
|
||||
{ value: 'processing', color: 'warning', label: '查询中' },
|
||||
] as const;
|
||||
|
||||
// 获取查询状态配置
|
||||
function getQueryStateConfig(state: string) {
|
||||
return (
|
||||
queryStateConfig.find((item) => item.value === state) || {
|
||||
color: 'default',
|
||||
label: state,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 字段名称映射
|
||||
const fieldNameMap: Record<string, string> = {
|
||||
// 基础字段
|
||||
name: '姓名',
|
||||
id_card: '身份证号',
|
||||
mobile: '手机号',
|
||||
code: '验证码',
|
||||
// 企业相关
|
||||
ent_name: '企业名称',
|
||||
ent_code: '统一社会信用代码',
|
||||
// 婚姻相关
|
||||
name_man: '男方姓名',
|
||||
id_card_man: '男方身份证号',
|
||||
name_woman: '女方姓名',
|
||||
id_card_woman: '女方身份证号',
|
||||
// 车辆相关
|
||||
car_type: '车辆类型',
|
||||
car_license: '车牌号',
|
||||
vin_code: '车架号',
|
||||
car_driving_permit: '行驶证号',
|
||||
// 银行卡相关
|
||||
bank_card: '银行卡号',
|
||||
// 学历相关
|
||||
certificate_number: '证书编号',
|
||||
// 日期相关
|
||||
start_date: '开始日期',
|
||||
};
|
||||
|
||||
// 获取字段显示名称
|
||||
function getFieldDisplayName(key: string): string {
|
||||
return fieldNameMap[key] || key;
|
||||
}
|
||||
|
||||
// 返回订单管理页面
|
||||
function handleBack() {
|
||||
router.push('/order');
|
||||
}
|
||||
|
||||
// 获取查询详情
|
||||
async function fetchQueryDetail() {
|
||||
if (!orderId) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getOrderQueryDetail(orderId);
|
||||
queryDetail.value = res;
|
||||
} catch {
|
||||
message.error('获取查询详情失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopied(_event: JsonViewerAction) {
|
||||
message.success('已复制JSON');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchQueryDetail();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<div class="p-4">
|
||||
<div class="mb-4 flex items-center">
|
||||
<Button @click="handleBack">
|
||||
<template #icon><MdiArrowLeft /></template>
|
||||
返回订单管理
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card :loading="loading" class="mb-4">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-lg font-medium">订单查询详情</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-500">查询状态:</span>
|
||||
<Tag
|
||||
v-if="queryDetail"
|
||||
:color="getQueryStateConfig(queryDetail.query_state).color"
|
||||
>
|
||||
{{ getQueryStateConfig(queryDetail.query_state).label }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="queryDetail">
|
||||
<Descriptions :column="2" bordered>
|
||||
<Descriptions.Item label="订单ID">
|
||||
{{ queryDetail.order_id }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="用户ID">
|
||||
{{ queryDetail.user_id }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="产品名称">
|
||||
{{ queryDetail.product_name }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{{ queryDetail.create_time }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">
|
||||
{{ queryDetail.update_time }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<template v-if="queryDetail">
|
||||
<Card class="mb-4">
|
||||
<template #title>
|
||||
<span class="text-lg font-medium">查询参数</span>
|
||||
</template>
|
||||
<template v-if="queryDetail.query_params">
|
||||
<Descriptions :column="2" bordered>
|
||||
<Descriptions.Item
|
||||
v-for="(value, key) in queryDetail.query_params"
|
||||
:key="key"
|
||||
:label="getFieldDisplayName(key)"
|
||||
>
|
||||
{{ value }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</template>
|
||||
<div v-else class="text-gray-500">暂无查询参数</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<template #title>
|
||||
<span class="text-lg font-medium">查询数据</span>
|
||||
</template>
|
||||
<template v-if="queryDetail.query_data?.length">
|
||||
<Collapse
|
||||
:default-active-key="
|
||||
queryDetail.query_data.map((_, index) => index)
|
||||
"
|
||||
>
|
||||
<Collapse.Panel
|
||||
v-for="(item, index) in queryDetail.query_data"
|
||||
:key="index"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg font-medium">{{
|
||||
item.feature.featureName
|
||||
}}</span>
|
||||
<Tag color="blue">API: {{ item.data.apiID }}</Tag>
|
||||
</div>
|
||||
<Tag
|
||||
:color="
|
||||
String(item.data.success) === 'true'
|
||||
? 'success'
|
||||
: 'error'
|
||||
"
|
||||
>
|
||||
{{
|
||||
String(item.data.success) === 'true'
|
||||
? '查询成功'
|
||||
: '查询失败'
|
||||
}}
|
||||
</Tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div v-if="item.data.data">
|
||||
<div class="mb-2 font-medium">查询结果:</div>
|
||||
<JsonViewer
|
||||
:value="item.data.data"
|
||||
copyable
|
||||
:expand-depth="2"
|
||||
boxed
|
||||
@copied="handleCopied"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-gray-500">
|
||||
查询时间: {{ item.data.timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</template>
|
||||
<div v-else class="py-4 text-center text-gray-500">暂无查询数据</div>
|
||||
</Card>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Card>
|
||||
<div class="py-8 text-center text-gray-500">
|
||||
{{ loading ? '加载中...' : '暂无查询数据' }}
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-collapse-header) {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
|
||||
:deep(.ant-collapse-content-box) {
|
||||
padding: 16px !important;
|
||||
}
|
||||
</style>
|
||||
138
apps/web-antd/src/views/platform-user/data.ts
Normal file
138
apps/web-antd/src/views/platform-user/data.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { PlatformUserApi } from '#/api/platform-user';
|
||||
|
||||
// 表单配置
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'mobile',
|
||||
label: '手机号',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'nickname',
|
||||
label: '昵称',
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'info',
|
||||
label: '备注信息',
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
fieldName: 'inside',
|
||||
label: '是否内部用户',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
options: [
|
||||
{ label: '是', value: 1 },
|
||||
{ label: '否', value: 0 },
|
||||
],
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 搜索表单配置
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'mobile',
|
||||
label: '手机号',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'nickname',
|
||||
label: '昵称',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
fieldName: 'inside',
|
||||
label: '是否内部用户',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: '是', value: 1 },
|
||||
{ label: '否', value: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'RangePicker',
|
||||
fieldName: 'create_time',
|
||||
label: '创建时间',
|
||||
componentProps: {
|
||||
showTime: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
export function useColumns<T = PlatformUserApi.PlatformUserItem>(
|
||||
onActionClick: OnActionClickFn<T>,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'id',
|
||||
title: '用户ID',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'mobile',
|
||||
title: '手机号',
|
||||
},
|
||||
{
|
||||
field: 'nickname',
|
||||
title: '昵称',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
field: 'info',
|
||||
title: '备注信息',
|
||||
},
|
||||
{
|
||||
field: 'inside',
|
||||
title: '是否内部用户',
|
||||
width: 100,
|
||||
formatter: ({ cellValue }) => (cellValue === 1 ? '是' : '否'),
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
width: 160,
|
||||
sortable: true,
|
||||
sortType: 'string',
|
||||
},
|
||||
{
|
||||
field: 'update_time',
|
||||
title: '更新时间',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'nickname',
|
||||
nameTitle: '用户',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
'edit', // 默认的编辑按钮
|
||||
],
|
||||
},
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
title: '操作',
|
||||
width: 130,
|
||||
},
|
||||
];
|
||||
}
|
||||
122
apps/web-antd/src/views/platform-user/list.vue
Normal file
122
apps/web-antd/src/views/platform-user/list.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeGridListeners,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { PlatformUserApi } from '#/api/platform-user';
|
||||
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getPlatformUserList } from '#/api/platform-user';
|
||||
|
||||
import { useColumns, useGridFormSchema } from './data';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
// 表单抽屉
|
||||
const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 表格配置
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
fieldMappingTime: [
|
||||
['create_time', ['create_time_start', 'create_time_end']],
|
||||
],
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridEvents: {
|
||||
sortChange: () => {
|
||||
gridApi.query();
|
||||
},
|
||||
} as VxeGridListeners<PlatformUserApi.PlatformUserItem>,
|
||||
gridOptions: {
|
||||
columns: useColumns(onActionClick),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
sortConfig: {
|
||||
remote: true,
|
||||
multiple: false,
|
||||
trigger: 'default',
|
||||
orders: ['asc', 'desc', null],
|
||||
resetPage: true,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page, sort }, formValues) => {
|
||||
const sortParams = sort
|
||||
? {
|
||||
order_by: sort.field,
|
||||
order_type: sort.order,
|
||||
}
|
||||
: {};
|
||||
|
||||
const res = await getPlatformUserList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
...sortParams,
|
||||
});
|
||||
|
||||
return {
|
||||
...res,
|
||||
sort: sort || null,
|
||||
};
|
||||
},
|
||||
},
|
||||
props: {
|
||||
result: 'items',
|
||||
total: 'total',
|
||||
},
|
||||
autoLoad: true,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
} as VxeTableGridOptions<PlatformUserApi.PlatformUserItem>,
|
||||
});
|
||||
|
||||
// 操作处理函数
|
||||
function onActionClick(
|
||||
e: OnActionClickParams<PlatformUserApi.PlatformUserItem>,
|
||||
) {
|
||||
switch (e.code) {
|
||||
case 'edit': {
|
||||
onEdit(e.row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑处理
|
||||
function onEdit(row: PlatformUserApi.PlatformUserItem) {
|
||||
formDrawerApi.setData(row).open();
|
||||
}
|
||||
|
||||
// 刷新处理
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormDrawer @success="onRefresh" />
|
||||
<Grid table-title="平台用户列表">
|
||||
<template #toolbar-tools>
|
||||
<!-- 暂时不需要添加按钮 -->
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
65
apps/web-antd/src/views/platform-user/modules/form.vue
Normal file
65
apps/web-antd/src/views/platform-user/modules/form.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PlatformUserApi } from '#/api/platform-user';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { updatePlatformUser } from '#/api/platform-user';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const formData = ref<PlatformUserApi.PlatformUserItem>();
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const id = ref();
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
const values = await formApi.getValues();
|
||||
drawerApi.lock();
|
||||
updatePlatformUser(
|
||||
id.value,
|
||||
values as PlatformUserApi.UpdatePlatformUserRequest,
|
||||
)
|
||||
.then(() => {
|
||||
emit('success');
|
||||
drawerApi.close();
|
||||
})
|
||||
.catch(() => {
|
||||
drawerApi.unlock();
|
||||
});
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<PlatformUserApi.PlatformUserItem>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
id.value = data.id;
|
||||
formApi.setValues(data);
|
||||
} else {
|
||||
id.value = undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getDrawerTitle = computed(() => {
|
||||
return formData.value?.id ? '编辑用户' : '创建用户';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer :title="getDrawerTitle">
|
||||
<Form />
|
||||
</Drawer>
|
||||
</template>
|
||||
128
apps/web-antd/src/views/product-manage/feature/data.ts
Normal file
128
apps/web-antd/src/views/product-manage/feature/data.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { FeatureApi } from '#/api/product-manage';
|
||||
|
||||
// 表单配置
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'api_id',
|
||||
label: '模块编号',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '描述',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'cost_price',
|
||||
label: '成本价',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
step: 0.01,
|
||||
formatter: (value: number) => {
|
||||
// 格式化为带千分位分隔符的货币
|
||||
const parts = value.toString().split('.');
|
||||
parts[0] = (parts[0] || '0').replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return `¥ ${parts.join('.')}`;
|
||||
},
|
||||
parser: (value: string) => {
|
||||
// 移除货币符号和千分位分隔符
|
||||
return value.replace(/[¥,\s]/g, '');
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 搜索表单配置
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'api_id',
|
||||
label: '模块编号',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '描述',
|
||||
},
|
||||
|
||||
];
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
export function useColumns<T = FeatureApi.FeatureItem>(
|
||||
onActionClick: OnActionClickFn<T>,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'api_id',
|
||||
title: '模块编号',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '描述',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'cost_price',
|
||||
title: '成本价',
|
||||
minWidth: 120,
|
||||
formatter: ({ cellValue }) => {
|
||||
// 格式化为带千分位分隔符的货币
|
||||
const value = cellValue?.toFixed(2) || '0.00';
|
||||
const parts = value.split('.');
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return `¥ ${parts.join('.')}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: 'update_time',
|
||||
title: '更新时间',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'name',
|
||||
nameTitle: '模块',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
options: [
|
||||
{
|
||||
code: 'edit',
|
||||
text: '编辑',
|
||||
},
|
||||
{
|
||||
code: 'delete',
|
||||
text: '删除',
|
||||
},
|
||||
{
|
||||
code: 'example',
|
||||
text: '示例配置',
|
||||
},
|
||||
],
|
||||
name: 'CellOperation',
|
||||
},
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
title: '操作',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
}
|
||||
138
apps/web-antd/src/views/product-manage/feature/list.vue
Normal file
138
apps/web-antd/src/views/product-manage/feature/list.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { FeatureApi } from '#/api/product-manage';
|
||||
|
||||
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { Plus } from '@vben/icons';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteFeature, getFeatureList } from '#/api/product-manage';
|
||||
|
||||
import { useColumns, useGridFormSchema } from './data';
|
||||
import ExampleConfig from './modules/example-config.vue';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
// 表单抽屉
|
||||
const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 示例配置弹窗
|
||||
const [ExampleConfigModal, exampleConfigModalApi] = useVbenModal({
|
||||
connectedComponent: ExampleConfig,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 表格配置
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useColumns(onActionClick),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getFeatureList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
} as VxeTableGridOptions<FeatureApi.FeatureItem>,
|
||||
});
|
||||
|
||||
// 操作处理函数
|
||||
function onActionClick(e: OnActionClickParams<FeatureApi.FeatureItem>) {
|
||||
switch (e.code) {
|
||||
case 'delete': {
|
||||
onDelete(e.row);
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
onEdit(e.row);
|
||||
break;
|
||||
}
|
||||
case 'example': {
|
||||
onExampleConfig(e.row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑处理
|
||||
function onEdit(row: FeatureApi.FeatureItem) {
|
||||
formDrawerApi.setData(row).open();
|
||||
}
|
||||
|
||||
// 删除处理
|
||||
function onDelete(row: FeatureApi.FeatureItem) {
|
||||
const hideLoading = message.loading({
|
||||
content: `正在删除 ${row.name}`,
|
||||
duration: 0,
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
deleteFeature(row.id)
|
||||
.then(() => {
|
||||
message.success({
|
||||
content: `删除 ${row.name} 成功`,
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
onRefresh();
|
||||
})
|
||||
.catch(() => {
|
||||
hideLoading();
|
||||
});
|
||||
}
|
||||
|
||||
// 刷新处理
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
// 创建处理
|
||||
function onCreate() {
|
||||
formDrawerApi.setData({}).open();
|
||||
}
|
||||
|
||||
// 示例配置处理
|
||||
function onExampleConfig(row: FeatureApi.FeatureItem) {
|
||||
exampleConfigModalApi.setData(row).open();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormDrawer @success="onRefresh" />
|
||||
<ExampleConfigModal @success="onRefresh" />
|
||||
<Grid table-title="模块列表">
|
||||
<template #toolbar-tools>
|
||||
<Button type="primary" @click="onCreate">
|
||||
<Plus class="size-5" />
|
||||
新增模块
|
||||
</Button>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FeatureApi } from '#/api/product-manage';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Input, message } from 'ant-design-vue';
|
||||
|
||||
import { configFeatureExample, getFeatureExample } from '#/api/product-manage';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const featureData = ref<FeatureApi.FeatureItem>();
|
||||
const exampleData = ref('');
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
if (!featureData.value) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await configFeatureExample({
|
||||
feature_id: featureData.value.id,
|
||||
data: exampleData.value,
|
||||
});
|
||||
message.success('示例配置保存成功');
|
||||
emit('success');
|
||||
modalApi.close();
|
||||
} catch (error) {
|
||||
console.error('保存示例配置失败:', error);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<FeatureApi.FeatureItem>();
|
||||
if (data) {
|
||||
featureData.value = data;
|
||||
loadExampleData();
|
||||
}
|
||||
} else {
|
||||
featureData.value = undefined;
|
||||
exampleData.value = '';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 加载示例数据
|
||||
async function loadExampleData() {
|
||||
if (!featureData.value) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await getFeatureExample(featureData.value.id);
|
||||
exampleData.value = response.data || '';
|
||||
} catch (error) {
|
||||
console.error('获取示例数据失败:', error);
|
||||
exampleData.value = '';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化JSON
|
||||
function formatJson() {
|
||||
try {
|
||||
if (exampleData.value) {
|
||||
const parsed = JSON.parse(exampleData.value);
|
||||
exampleData.value = JSON.stringify(parsed, null, 2);
|
||||
}
|
||||
} catch {
|
||||
message.error('JSON格式错误,无法格式化');
|
||||
}
|
||||
}
|
||||
|
||||
// 清空数据
|
||||
function clearData() {
|
||||
exampleData.value = '';
|
||||
}
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
return featureData.value
|
||||
? `示例配置 - ${featureData.value.name}`
|
||||
: '示例配置';
|
||||
});
|
||||
|
||||
const isJsonValid = computed(() => {
|
||||
if (!exampleData.value) return true;
|
||||
try {
|
||||
JSON.parse(exampleData.value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="modalTitle" class="w-[1200px]">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="text-sm font-medium">示例数据 (JSON格式)</label>
|
||||
<div class="space-x-2">
|
||||
<Button size="small" @click="formatJson">格式化</Button>
|
||||
<Button size="small" @click="clearData">清空</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input.TextArea
|
||||
v-model:value="exampleData"
|
||||
:loading="loading"
|
||||
:rows="15"
|
||||
:status="isJsonValid ? undefined : 'error'"
|
||||
placeholder="请输入JSON格式的示例数据..."
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
<div v-if="!isJsonValid" class="mt-1 text-xs text-red-500">
|
||||
JSON格式错误,请检查语法
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500">
|
||||
<p>提示:</p>
|
||||
<ul class="list-disc space-y-1 pl-4">
|
||||
<li>如果当前功能没有配置示例数据,输入框将为空</li>
|
||||
<li>可以在此输入JSON格式的示例数据</li>
|
||||
<li>点击"格式化"按钮可以美化JSON格式</li>
|
||||
<li>保存后该示例数据将用于功能展示</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end space-x-2">
|
||||
<Button @click="modalApi.close">取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="!isJsonValid"
|
||||
@click="modalApi.onConfirm"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FeatureApi } from '#/api/product-manage';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createFeature, updateFeature } from '#/api/product-manage';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const formData = ref<FeatureApi.FeatureItem>();
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const id = ref();
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
const values = await formApi.getValues();
|
||||
drawerApi.lock();
|
||||
try {
|
||||
await (id.value
|
||||
? updateFeature(id.value, values as FeatureApi.UpdateFeatureRequest)
|
||||
: createFeature(values as FeatureApi.CreateFeatureRequest));
|
||||
emit('success');
|
||||
drawerApi.close();
|
||||
} catch {
|
||||
drawerApi.unlock();
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<FeatureApi.FeatureItem>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
id.value = data.id;
|
||||
formApi.setValues(data);
|
||||
} else {
|
||||
id.value = undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getDrawerTitle = computed(() => {
|
||||
return formData.value?.id ? '编辑模块' : '创建模块';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer :title="getDrawerTitle">
|
||||
<Form />
|
||||
</Drawer>
|
||||
</template>
|
||||
141
apps/web-antd/src/views/product-manage/product/data.ts
Normal file
141
apps/web-antd/src/views/product-manage/product/data.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { ProductApi } from '#/api/product-manage';
|
||||
|
||||
// 表单配置
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'product_name',
|
||||
label: '产品名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'product_en',
|
||||
label: '产品编号',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'RichText',
|
||||
fieldName: 'description',
|
||||
label: '描述',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'notes',
|
||||
label: '备注',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
},
|
||||
fieldName: 'cost_price',
|
||||
label: '成本价',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
precision: 2,
|
||||
},
|
||||
fieldName: 'sell_price',
|
||||
label: '售价',
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 搜索表单配置
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'product_name',
|
||||
label: '产品名称',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'product_en',
|
||||
label: '产品编号',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
export function useColumns<T = ProductApi.ProductItem>(
|
||||
onActionClick: OnActionClickFn<T>,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'product_name',
|
||||
title: '产品名称',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
field: 'product_en',
|
||||
title: '产品编号',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '描述',
|
||||
},
|
||||
{
|
||||
field: 'cost_price',
|
||||
formatter: ({ cellValue }) => `¥${cellValue.toFixed(2)}`,
|
||||
title: '成本价',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
field: 'sell_price',
|
||||
formatter: ({ cellValue }) => `¥${cellValue.toFixed(2)}`,
|
||||
title: '售价',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
field: 'update_time',
|
||||
title: '更新时间',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'product_name',
|
||||
nameTitle: '产品',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
options: [
|
||||
{
|
||||
code: 'edit',
|
||||
text: '编辑',
|
||||
},
|
||||
{
|
||||
code: 'delete',
|
||||
text: '删除',
|
||||
},
|
||||
{
|
||||
code: 'features',
|
||||
text: '模块管理',
|
||||
},
|
||||
],
|
||||
name: 'CellOperation',
|
||||
},
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
title: '操作',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
}
|
||||
143
apps/web-antd/src/views/product-manage/product/list.vue
Normal file
143
apps/web-antd/src/views/product-manage/product/list.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { ProductApi } from '#/api/product-manage';
|
||||
|
||||
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { Plus } from '@vben/icons';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteProduct, getProductList } from '#/api/product-manage';
|
||||
|
||||
import { useColumns, useGridFormSchema } from './data';
|
||||
import FeatureManage from './modules/feature-manage.vue';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
// 表单抽屉
|
||||
const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 模块管理弹窗
|
||||
const [FeatureManageModal, featureManageModalApi] = useVbenModal({
|
||||
connectedComponent: FeatureManage,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 表格配置
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useColumns(onActionClick),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getProductList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
} as VxeTableGridOptions<ProductApi.ProductItem>,
|
||||
});
|
||||
|
||||
// 操作处理函数
|
||||
function onActionClick(e: OnActionClickParams<ProductApi.ProductItem>) {
|
||||
switch (e.code) {
|
||||
case 'delete': {
|
||||
onDelete(e.row);
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
onEdit(e.row);
|
||||
break;
|
||||
}
|
||||
case 'features': {
|
||||
onManageFeatures(e.row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑处理
|
||||
function onEdit(row: ProductApi.ProductItem) {
|
||||
formDrawerApi.setData(row).open();
|
||||
}
|
||||
|
||||
// 删除处理
|
||||
function onDelete(row: ProductApi.ProductItem) {
|
||||
const hideLoading = message.loading({
|
||||
content: `正在删除 ${row.product_name}`,
|
||||
duration: 0,
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
deleteProduct(row.id)
|
||||
.then(() => {
|
||||
message.success({
|
||||
content: `删除 ${row.product_name} 成功`,
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
onRefresh();
|
||||
})
|
||||
.catch(() => {
|
||||
hideLoading();
|
||||
});
|
||||
}
|
||||
|
||||
// 刷新处理
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
// 创建处理
|
||||
function onCreate() {
|
||||
formDrawerApi.setData({}).open();
|
||||
}
|
||||
|
||||
// 模块管理处理
|
||||
function onManageFeatures(row: ProductApi.ProductItem) {
|
||||
featureManageModalApi
|
||||
.setData({
|
||||
productId: row.id,
|
||||
productName: row.product_name,
|
||||
})
|
||||
.open();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormDrawer @success="onRefresh" />
|
||||
<FeatureManageModal @success="onRefresh" />
|
||||
<Grid table-title="产品列表">
|
||||
<template #toolbar-tools>
|
||||
<Button type="primary" @click="onCreate">
|
||||
<Plus class="size-5" />
|
||||
新增产品
|
||||
</Button>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,431 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
// @ts-expect-error: sortablejs 没有类型声明
|
||||
import type { SortableEvent } from 'sortablejs';
|
||||
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { FeatureApi } from '#/api/product-manage/feature';
|
||||
import type { ProductApi } from '#/api/product-manage/product';
|
||||
|
||||
import { h, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { useSortable } from '@vben-core/composables';
|
||||
|
||||
import {
|
||||
Modal as AModal,
|
||||
Button,
|
||||
message,
|
||||
Switch,
|
||||
Table,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getFeatureList } from '#/api/product-manage/feature';
|
||||
import {
|
||||
getProductFeatureList,
|
||||
updateProduct,
|
||||
updateProductFeatures,
|
||||
} from '#/api/product-manage/product';
|
||||
|
||||
import { useColumns, useGridFormSchema } from '../../feature/data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
// 定义数据接口
|
||||
interface ModalData {
|
||||
productId: number;
|
||||
productName: string;
|
||||
}
|
||||
|
||||
// 临时存储的已关联模块列表
|
||||
interface TempFeatureItem
|
||||
extends Omit<
|
||||
ProductApi.ProductFeatureListItem,
|
||||
'create_time' | 'id' | 'product_id' | 'update_time'
|
||||
> {
|
||||
temp_id: string; // 临时ID,用于区分新增的模块
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '管理产品模块',
|
||||
destroyOnClose: true,
|
||||
onOpenChange: (isOpen) => {
|
||||
if (isOpen) {
|
||||
loadFeatureList();
|
||||
}
|
||||
},
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const { productId } = modalApi.getData<ModalData>();
|
||||
// 准备要保存的数据
|
||||
const features = tempFeatureList.value.map((item) => ({
|
||||
feature_id: item.feature_id,
|
||||
sort: item.sort,
|
||||
enable: item.enable,
|
||||
is_important: item.is_important,
|
||||
}));
|
||||
|
||||
// 更新产品模块关联
|
||||
await updateProductFeatures(productId, { features });
|
||||
|
||||
// 计算关联模块的总成本(只计算启用的模块)
|
||||
let totalCost = 0;
|
||||
const enabledFeatures = tempFeatureList.value.filter(item => item.enable === 1);
|
||||
|
||||
// 使用缓存的模块数据计算总成本
|
||||
for (const feature of enabledFeatures) {
|
||||
const featureDetail = allFeaturesCache.value.find(f => f.id === feature.feature_id);
|
||||
if (featureDetail) {
|
||||
totalCost += featureDetail.cost_price || 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新产品成本价
|
||||
await updateProduct(productId, { cost_price: totalCost });
|
||||
|
||||
message.success(`保存成功,产品成本已更新为: ¥${totalCost.toFixed(2)}`);
|
||||
emit('success');
|
||||
modalApi.close(); // 保存成功后关闭Modal
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
message.error('保存失败');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const tempFeatureList = ref<TempFeatureItem[]>([]);
|
||||
// 存储模块详细信息,用于成本计算
|
||||
const allFeaturesCache = ref<FeatureApi.FeatureItem[]>([]);
|
||||
|
||||
// 表格配置
|
||||
const [Grid] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: true,
|
||||
showCollapseButton: false,
|
||||
},
|
||||
separator: false,
|
||||
gridOptions: {
|
||||
columns: (useColumns(onActionClick) || []).map((col) => {
|
||||
if (col.field === 'operation' && col.cellRender) {
|
||||
return {
|
||||
...col,
|
||||
cellRender: {
|
||||
...col.cellRender,
|
||||
options: [
|
||||
{
|
||||
code: 'add',
|
||||
text: '添加',
|
||||
show: (row: FeatureApi.FeatureItem) => {
|
||||
return !tempFeatureList.value.some(
|
||||
(item) => item.feature_id === row.id,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'added',
|
||||
text: '已添加',
|
||||
disabled: true,
|
||||
show: (row: FeatureApi.FeatureItem) => {
|
||||
return tempFeatureList.value.some(
|
||||
(item) => item.feature_id === row.id,
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
return col;
|
||||
}),
|
||||
height: 500,
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
pageSize: 8,
|
||||
pageSizes: [8, 20, 50, 100],
|
||||
pagerCount: 5,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getFeatureList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
} as VxeTableGridOptions<FeatureApi.FeatureItem>,
|
||||
});
|
||||
|
||||
// 已关联模块表格列配置
|
||||
const columns: TableColumnsType<TempFeatureItem> = [
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '模块编码',
|
||||
dataIndex: 'api_id',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '模块描述',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
title: '是否启用',
|
||||
dataIndex: 'enable',
|
||||
width: 100,
|
||||
customRender: ({ record }) => {
|
||||
return h(Switch, {
|
||||
checked: record.enable === 1,
|
||||
onChange: (checked: any) => {
|
||||
record.enable = checked ? 1 : 0;
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '是否重要',
|
||||
dataIndex: 'is_important',
|
||||
width: 100,
|
||||
customRender: ({ record }) => {
|
||||
return h(Switch, {
|
||||
checked: record.is_important === 1,
|
||||
onChange: (checked: any) => {
|
||||
record.is_important = checked ? 1 : 0;
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'operation',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
customRender: ({ record }) => {
|
||||
return h(
|
||||
Button,
|
||||
{
|
||||
type: 'link',
|
||||
danger: true,
|
||||
onClick: () => handleRemoveFeature(record),
|
||||
},
|
||||
() => '移除',
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 加载模块列表
|
||||
async function loadFeatureList() {
|
||||
const { productId, productName } = modalApi.getData<ModalData>();
|
||||
// 更新标题
|
||||
modalApi.setState({ title: `管理产品模块 - ${productName}` });
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
// 获取产品已关联的模块列表
|
||||
const res = await getProductFeatureList(productId);
|
||||
// 转换为临时数据格式
|
||||
let tempList = res.map((item) => ({
|
||||
...item,
|
||||
temp_id: `existing_${item.id}`,
|
||||
}));
|
||||
// 对sort字段进行排序,如果全为0则按原顺序赋递增sort
|
||||
const allSortZero = tempList.every((item) => !item.sort || item.sort === 0);
|
||||
if (allSortZero) {
|
||||
tempList.forEach((item, idx) => {
|
||||
item.sort = idx + 1;
|
||||
});
|
||||
} else {
|
||||
tempList = [...tempList]
|
||||
.sort((a, b) => (a.sort || 0) - (b.sort || 0))
|
||||
.map((item, idx) => ({ ...item, sort: idx + 1 }));
|
||||
}
|
||||
tempFeatureList.value = tempList;
|
||||
|
||||
// 获取并缓存所有模块数据(用于成本计算)
|
||||
const allFeaturesRes = await getFeatureList({ page: 1, pageSize: 1000 });
|
||||
allFeaturesCache.value = allFeaturesRes.items || [];
|
||||
|
||||
initSortable();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化拖拽排序
|
||||
async function initSortable() {
|
||||
const el = document.querySelector('.ant-table-tbody');
|
||||
if (!el) return;
|
||||
|
||||
const { initializeSortable } = useSortable(el as HTMLElement, {
|
||||
animation: 150,
|
||||
handle: '.ant-table-row',
|
||||
onEnd: async (evt: SortableEvent) => {
|
||||
let { newIndex, oldIndex } = evt;
|
||||
// 兼容性保护,如果为 undefined/null 则不处理
|
||||
if (
|
||||
typeof newIndex !== 'number' ||
|
||||
typeof oldIndex !== 'number' ||
|
||||
newIndex === oldIndex
|
||||
)
|
||||
return;
|
||||
|
||||
// 1-based 转为 0-based
|
||||
newIndex = newIndex - 1;
|
||||
oldIndex = oldIndex - 1;
|
||||
|
||||
// 重新排序列表
|
||||
const newList = [...tempFeatureList.value];
|
||||
const [removed] = newList.splice(oldIndex, 1);
|
||||
if (removed) {
|
||||
newList.splice(newIndex, 0, removed);
|
||||
// 更新排序值
|
||||
newList.forEach((item, index) => {
|
||||
item.sort = index + 1;
|
||||
});
|
||||
tempFeatureList.value = newList;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await initializeSortable();
|
||||
}
|
||||
// 操作处理函数
|
||||
function onActionClick(e: OnActionClickParams<FeatureApi.FeatureItem>) {
|
||||
switch (e.code) {
|
||||
case 'add': {
|
||||
handleAddFeature(e.row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 处理添加模块
|
||||
function handleAddFeature(feature: FeatureApi.FeatureItem) {
|
||||
// 获取当前最大排序值
|
||||
const maxSort = Math.max(
|
||||
...tempFeatureList.value.map((item) => item.sort),
|
||||
0,
|
||||
);
|
||||
|
||||
// 添加到临时列表
|
||||
tempFeatureList.value.push({
|
||||
feature_id: feature.id,
|
||||
api_id: feature.api_id,
|
||||
name: feature.name,
|
||||
sort: maxSort + 1,
|
||||
enable: 1,
|
||||
is_important: 0,
|
||||
temp_id: `new_${Date.now()}_${feature.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 处理移除模块
|
||||
function handleRemoveFeature(record: TempFeatureItem) {
|
||||
AModal.confirm({
|
||||
title: '确认移除',
|
||||
content: `确定要移除模块"${record.name}"吗?`,
|
||||
onOk: () => {
|
||||
tempFeatureList.value = tempFeatureList.value.filter(
|
||||
(item) => item.temp_id !== record.temp_id,
|
||||
);
|
||||
// 重新排序
|
||||
tempFeatureList.value.forEach((item, index) => {
|
||||
item.sort = index + 1;
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 计算已启用模块的总成本
|
||||
function calculateTotalCost() {
|
||||
let totalCost = 0;
|
||||
const enabledFeatures = tempFeatureList.value.filter(item => item.enable === 1);
|
||||
|
||||
// 使用缓存的模块数据计算总成本
|
||||
for (const feature of enabledFeatures) {
|
||||
const featureDetail = allFeaturesCache.value.find(f => f.id === feature.feature_id);
|
||||
if (featureDetail) {
|
||||
totalCost += featureDetail.cost_price || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return totalCost;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[calc(100vw-200px)]">
|
||||
<div class="px-2">
|
||||
<div class="flex gap-4">
|
||||
<!-- 左侧:可选模块列表 -->
|
||||
<div class="w-[600px] flex-shrink-0">
|
||||
<!-- <div class="mb-2 text-base font-medium">可选模块</div>
|
||||
<div class="mb-4 text-sm text-gray-500">
|
||||
提示:点击添加可以快速添加模块到已关联模块列表
|
||||
</div> -->
|
||||
<Grid />
|
||||
</div>
|
||||
<!-- 右侧:已关联模块列表 -->
|
||||
<div class="flex-1">
|
||||
<div class="mb-2 text-base font-medium">已关联模块</div>
|
||||
<div class="mb-2 p-3 bg-gray-50 rounded">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm font-medium">已启用模块总成本:</span>
|
||||
<span class="text-lg font-bold text-red-600">¥{{ calculateTotalCost().toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 text-sm text-gray-500">
|
||||
提示:可以通过拖拽行来调整模块顺序,通过开关控制模块的启用状态和重要程度
|
||||
</div>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="tempFeatureList"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
:row-key="(record) => record.temp_id"
|
||||
:scroll="{ y: 500 }"
|
||||
class="sortable-table"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'sort'">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2 cursor-move">⋮⋮</span>
|
||||
{{ record.sort }}
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.sortable-table .ant-table-row {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.sortable-table .ant-table-row.sortable-ghost {
|
||||
background: #fafafa;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ProductApi } from '#/api/product-manage';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createProduct, updateProduct } from '#/api/product-manage';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const formData = ref<ProductApi.ProductItem>();
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const id = ref();
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
const values = await formApi.getValues();
|
||||
drawerApi.lock();
|
||||
try {
|
||||
await (id.value
|
||||
? updateProduct(id.value, values as ProductApi.UpdateProductRequest)
|
||||
: createProduct(values as ProductApi.CreateProductRequest));
|
||||
emit('success');
|
||||
drawerApi.close();
|
||||
} catch {
|
||||
drawerApi.unlock();
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<ProductApi.ProductItem>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
id.value = data.id;
|
||||
formApi.setValues(data);
|
||||
} else {
|
||||
id.value = undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getDrawerTitle = computed(() => {
|
||||
return formData.value?.id ? '编辑产品' : '创建产品';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer :title="getDrawerTitle"> <Form /> </Drawer>
|
||||
</template>
|
||||
124
apps/web-antd/src/views/promotion/analytics/index.vue
Normal file
124
apps/web-antd/src/views/promotion/analytics/index.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AnalysisOverviewItem } from '@vben/common-ui';
|
||||
|
||||
import type { PromotionAnalyticsApi } from '#/api/promotion/analytics';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { AnalysisChartCard, AnalysisOverview } from '@vben/common-ui';
|
||||
import { SvgCakeIcon, SvgCardIcon, SvgDownloadIcon } from '@vben/icons';
|
||||
|
||||
import { DatePicker } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { statsHistory, statsTotal } from '#/api/promotion/analytics';
|
||||
|
||||
import PromotionTrends from './promotion-trends.vue';
|
||||
|
||||
const overviewItems = ref<AnalysisOverviewItem[]>([
|
||||
{
|
||||
icon: SvgCardIcon,
|
||||
title: '今日点击数',
|
||||
totalTitle: '累计',
|
||||
totalValue: 0,
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
icon: SvgCakeIcon,
|
||||
title: '今日付费次数',
|
||||
totalTitle: '累计',
|
||||
totalValue: 0,
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
icon: SvgDownloadIcon,
|
||||
title: '今日付费金额',
|
||||
totalTitle: '累计',
|
||||
totalValue: 0,
|
||||
value: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const trendData = ref<PromotionAnalyticsApi.TrendData[]>([]);
|
||||
const dateRange = ref<[dayjs.Dayjs, dayjs.Dayjs]>([
|
||||
dayjs().subtract(7, 'day'),
|
||||
dayjs(),
|
||||
]);
|
||||
|
||||
const fetchOverview = async () => {
|
||||
try {
|
||||
const data = await statsTotal();
|
||||
overviewItems.value = [
|
||||
{
|
||||
icon: SvgCardIcon,
|
||||
title: '今日点击数',
|
||||
totalTitle: '累计',
|
||||
totalValue: data.total_click_count,
|
||||
value: data.today_click_count,
|
||||
decimals: 0,
|
||||
},
|
||||
{
|
||||
icon: SvgCakeIcon,
|
||||
title: '今日付费次数',
|
||||
totalTitle: '累计',
|
||||
totalValue: data.total_pay_count,
|
||||
value: data.today_pay_count,
|
||||
decimals: 0,
|
||||
},
|
||||
{
|
||||
icon: SvgDownloadIcon,
|
||||
title: '今日付费金额',
|
||||
totalTitle: '累计',
|
||||
totalValue: data.total_pay_amount,
|
||||
value: data.today_pay_amount,
|
||||
decimals: 2,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('获取概览数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTrendData = async () => {
|
||||
try {
|
||||
const data = await statsHistory({
|
||||
start_date: dateRange.value[0].format('YYYY-MM-DD'),
|
||||
end_date: dateRange.value[1].format('YYYY-MM-DD'),
|
||||
});
|
||||
trendData.value = data;
|
||||
} catch (error) {
|
||||
console.error('获取趋势数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateChange = () => {
|
||||
fetchTrendData();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchOverview();
|
||||
fetchTrendData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<AnalysisOverview :items="overviewItems" />
|
||||
<div class="mt-5">
|
||||
<AnalysisChartCard title="数据趋势">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex justify-end">
|
||||
<DatePicker.RangePicker
|
||||
v-model:value="dateRange"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</div>
|
||||
<PromotionTrends type="count" :data="trendData" />
|
||||
<PromotionTrends type="amount" :data="trendData" />
|
||||
</div>
|
||||
</AnalysisChartCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import type { PromotionAnalyticsApi } from '#/api/promotion/analytics';
|
||||
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
const props = defineProps<{
|
||||
data: PromotionAnalyticsApi.TrendData[];
|
||||
}>();
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
const renderChart = () => {
|
||||
// 计算转化率数据
|
||||
const totalClicks = props.data.reduce(
|
||||
(sum, item) => sum + item.click_count,
|
||||
0,
|
||||
);
|
||||
const totalPays = props.data.reduce((sum, item) => sum + item.pay_count, 0);
|
||||
const totalAmount = props.data.reduce(
|
||||
(sum, item) => sum + item.pay_amount,
|
||||
0,
|
||||
);
|
||||
|
||||
const conversionRate = totalClicks > 0 ? (totalPays / totalClicks) * 100 : 0;
|
||||
const averageAmount = totalPays > 0 ? totalAmount / totalPays : 0;
|
||||
|
||||
renderEcharts({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b} : {c}%',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '转化率',
|
||||
type: 'funnel',
|
||||
left: '10%',
|
||||
top: 60,
|
||||
bottom: 60,
|
||||
width: '80%',
|
||||
min: 0,
|
||||
max: 100,
|
||||
minSize: '0%',
|
||||
maxSize: '100%',
|
||||
sort: 'descending',
|
||||
gap: 2,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
},
|
||||
labelLine: {
|
||||
length: 10,
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
type: 'solid',
|
||||
},
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1,
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
data: [
|
||||
{ value: 100, name: '点击量' },
|
||||
{ value: conversionRate, name: '付费转化率' },
|
||||
{ value: averageAmount, name: '平均付费金额' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
watch(() => props.data, renderChart, { deep: true });
|
||||
|
||||
onMounted(() => {
|
||||
renderChart();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EchartsUI ref="chartRef" class="h-[400px]" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
146
apps/web-antd/src/views/promotion/analytics/promotion-trends.vue
Normal file
146
apps/web-antd/src/views/promotion/analytics/promotion-trends.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import type { PromotionAnalyticsApi } from '#/api/promotion/analytics';
|
||||
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
const props = defineProps<{
|
||||
data: PromotionAnalyticsApi.TrendData[];
|
||||
type: 'amount' | 'count';
|
||||
}>();
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
const renderChart = () => {
|
||||
const dates = props.data.map((item) => item.stats_date);
|
||||
const clickCounts = props.data.map((item) => item.click_count);
|
||||
const payCounts = props.data.map((item) => item.pay_count);
|
||||
const payAmounts = props.data.map((item) => item.pay_amount);
|
||||
|
||||
renderEcharts({
|
||||
grid: {
|
||||
top: 60,
|
||||
left: 50,
|
||||
right: 50,
|
||||
bottom: 50,
|
||||
},
|
||||
legend: {
|
||||
data: props.type === 'count' ? ['点击数', '付费次数'] : ['付费金额'],
|
||||
top: 20,
|
||||
},
|
||||
series:
|
||||
props.type === 'count'
|
||||
? [
|
||||
{
|
||||
name: '点击数',
|
||||
type: 'line',
|
||||
data: clickCounts,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
itemStyle: {
|
||||
color: '#5ab1ef',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '付费次数',
|
||||
type: 'line',
|
||||
data: payCounts,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
itemStyle: {
|
||||
color: '#019680',
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: '付费金额',
|
||||
type: 'line',
|
||||
data: payAmounts,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
itemStyle: {
|
||||
color: '#b6a2de',
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0,
|
||||
color: 'rgba(182, 162, 222, 0.3)',
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: 'rgba(182, 162, 222, 0.1)',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
label: {
|
||||
backgroundColor: '#6a7985',
|
||||
},
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: {
|
||||
formatter: (value: string) => value.slice(5), // 只显示月-日
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'solid',
|
||||
width: 1,
|
||||
},
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
formatter: (value: number) => {
|
||||
if (props.type === 'amount') {
|
||||
return `¥${value}`;
|
||||
}
|
||||
return value.toString();
|
||||
},
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'solid',
|
||||
width: 1,
|
||||
},
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
watch(() => props.data, renderChart, { deep: true });
|
||||
watch(() => props.type, renderChart);
|
||||
|
||||
onMounted(() => {
|
||||
renderChart();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EchartsUI ref="chartRef" class="h-[400px]" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
92
apps/web-antd/src/views/promotion/link/data.ts
Normal file
92
apps/web-antd/src/views/promotion/link/data.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { SystemRoleApi } from '#/api';
|
||||
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '名称',
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '名称',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'url',
|
||||
label: '链接',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useColumns<T = SystemRoleApi.SystemRole>(
|
||||
onActionClick: OnActionClickFn<T>,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'name',
|
||||
title: '名称',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'url',
|
||||
title: '链接',
|
||||
minWidth: 250,
|
||||
slots: { default: 'url' },
|
||||
},
|
||||
{
|
||||
field: 'click_count',
|
||||
title: '累计点击数',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'pay_count',
|
||||
title: '付费次数',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'pay_amount',
|
||||
title: '付费金额',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: 'last_click_time',
|
||||
title: '最后点击时间',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: 'last_pay_time',
|
||||
title: '最后付费时间',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'name',
|
||||
nameTitle: '名称',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
},
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
title: '操作',
|
||||
width: 130,
|
||||
},
|
||||
];
|
||||
}
|
||||
142
apps/web-antd/src/views/promotion/link/index.vue
Normal file
142
apps/web-antd/src/views/promotion/link/index.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { PromotionLinkApi } from '#/api';
|
||||
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
import { Copy, Plus } from '@vben/icons';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deletePromotionLink, getPromotionLinkList } from '#/api';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useColumns, useGridFormSchema } from './data';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
fieldMappingTime: [['create_time', ['startTime', 'endTime']]],
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useColumns(onActionClick),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getPromotionLinkList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
} as VxeTableGridOptions<PromotionLinkApi.PromotionLink>,
|
||||
});
|
||||
|
||||
function onActionClick(
|
||||
e: OnActionClickParams<PromotionLinkApi.PromotionLinkItem>,
|
||||
) {
|
||||
switch (e.code) {
|
||||
case 'delete': {
|
||||
onDelete(e.row);
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
onEdit(e.row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onEdit(row: PromotionLinkApi.PromotionLinkItem) {
|
||||
formDrawerApi.setData(row).open();
|
||||
}
|
||||
|
||||
function onDelete(row: PromotionLinkApi.PromotionLinkItem) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
deletePromotionLink(row.id.toString())
|
||||
.then(() => {
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
onRefresh();
|
||||
})
|
||||
.catch(() => {
|
||||
hideLoading();
|
||||
});
|
||||
}
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
function onCreate() {
|
||||
formDrawerApi.setData({}).open();
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => {
|
||||
message.success('复制成功');
|
||||
},
|
||||
() => {
|
||||
message.error('复制失败');
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormDrawer @success="onRefresh" />
|
||||
<Grid table-title="链接列表">
|
||||
<template #toolbar-tools>
|
||||
<Button type="primary" @click="onCreate">
|
||||
<Plus class="size-5" />
|
||||
创建链接
|
||||
</Button>
|
||||
</template>
|
||||
<template #url="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate">{{ row.url }}</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
class="!p-0"
|
||||
@click="copyToClipboard(row.url)"
|
||||
>
|
||||
<Copy class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
85
apps/web-antd/src/views/promotion/link/modules/form.vue
Normal file
85
apps/web-antd/src/views/promotion/link/modules/form.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PromotionLinkApi } from '#/api';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createPromotionLink, updatePromotionLink } from '#/api';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emits = defineEmits(['success']);
|
||||
|
||||
const formData = ref<PromotionLinkApi.PromotionLinkItem>();
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const id = ref();
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
const values =
|
||||
await formApi.getValues<Omit<PromotionLinkApi.PromotionLinkItem, 'id'>>();
|
||||
drawerApi.lock();
|
||||
(id.value
|
||||
? updatePromotionLink(id.value, values)
|
||||
: createPromotionLink(values)
|
||||
)
|
||||
.then(() => {
|
||||
emits('success');
|
||||
drawerApi.close();
|
||||
})
|
||||
.catch(() => {
|
||||
drawerApi.unlock();
|
||||
});
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<PromotionLinkApi.PromotionLinkItem>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
id.value = data.id;
|
||||
formApi.setValues(data);
|
||||
} else {
|
||||
id.value = undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getDrawerTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('common.edit', $t('system.role.name'))
|
||||
: $t('common.create', $t('system.role.name'));
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Drawer :title="getDrawerTitle">
|
||||
<Form />
|
||||
</Drawer>
|
||||
</template>
|
||||
<style lang="css" scoped>
|
||||
:deep(.ant-tree-title) {
|
||||
.tree-actions {
|
||||
display: none;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-tree-title:hover) {
|
||||
.tree-actions {
|
||||
display: flex;
|
||||
flex: auto;
|
||||
justify-content: flex-end;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
178
apps/web-antd/src/views/system/api/data.ts
Normal file
178
apps/web-antd/src/views/system/api/data.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { SystemApiApi } from '#/api';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'api_name',
|
||||
label: $t('system.api.apiName'),
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'api_code',
|
||||
label: $t('system.api.apiCode'),
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
class: 'w-full',
|
||||
placeholder: '请选择请求方法',
|
||||
options: [
|
||||
{ label: 'GET', value: 'GET' },
|
||||
{ label: 'POST', value: 'POST' },
|
||||
{ label: 'PUT', value: 'PUT' },
|
||||
{ label: 'DELETE', value: 'DELETE' },
|
||||
],
|
||||
},
|
||||
fieldName: 'method',
|
||||
label: $t('system.api.method'),
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'url',
|
||||
label: $t('system.api.url'),
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
options: [
|
||||
{ label: $t('common.enabled'), value: 1 },
|
||||
{ label: $t('common.disabled'), value: 0 },
|
||||
],
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'status',
|
||||
label: $t('system.api.status'),
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'description',
|
||||
label: $t('system.api.description'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'api_name',
|
||||
label: $t('system.api.apiName'),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'api_code',
|
||||
label: $t('system.api.apiCode'),
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
class: 'w-full',
|
||||
options: [
|
||||
{ label: 'GET', value: 'GET' },
|
||||
{ label: 'POST', value: 'POST' },
|
||||
{ label: 'PUT', value: 'PUT' },
|
||||
{ label: 'DELETE', value: 'DELETE' },
|
||||
],
|
||||
},
|
||||
fieldName: 'method',
|
||||
label: $t('system.api.method'),
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
class: 'w-full',
|
||||
options: [
|
||||
{ label: $t('common.enabled'), value: 1 },
|
||||
{ label: $t('common.disabled'), value: 0 },
|
||||
],
|
||||
},
|
||||
fieldName: 'status',
|
||||
label: $t('system.api.status'),
|
||||
},
|
||||
{
|
||||
component: 'RangePicker',
|
||||
fieldName: 'create_time',
|
||||
label: $t('system.api.createTime'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useColumns<T = SystemApiApi.SystemApiItem>(
|
||||
onActionClick: OnActionClickFn<T>,
|
||||
onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'api_name',
|
||||
title: $t('system.api.apiName'),
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
field: 'api_code',
|
||||
title: $t('system.api.apiCode'),
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
field: 'method',
|
||||
title: $t('system.api.method'),
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'url',
|
||||
title: $t('system.api.url'),
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
cellRender: {
|
||||
attrs: { beforeChange: onStatusChange },
|
||||
name: onStatusChange ? 'CellSwitch' : 'CellTag',
|
||||
},
|
||||
field: 'status',
|
||||
title: $t('system.api.status'),
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
minWidth: 150,
|
||||
title: $t('system.api.description'),
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: $t('system.api.createTime'),
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'api_name',
|
||||
nameTitle: $t('system.api.apiName'),
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
'edit', // 默认的编辑按钮
|
||||
'delete', // 默认的删除按钮
|
||||
],
|
||||
},
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
title: $t('system.api.operation'),
|
||||
width: 130,
|
||||
},
|
||||
];
|
||||
}
|
||||
171
apps/web-antd/src/views/system/api/list.vue
Normal file
171
apps/web-antd/src/views/system/api/list.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { SystemApiApi } from '#/api';
|
||||
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
import { Plus } from '@vben/icons';
|
||||
|
||||
import { Button, message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteApi, getApiList, updateApi } from '#/api';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useColumns, useGridFormSchema } from './data';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
fieldMappingTime: [['create_time', ['startTime', 'endTime']]],
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useColumns(onActionClick, onStatusChange),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getApiList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
props: {
|
||||
result: 'list',
|
||||
total: 'total',
|
||||
},
|
||||
autoLoad: true,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
} as VxeTableGridOptions<SystemApiApi.SystemApiItem>,
|
||||
});
|
||||
|
||||
function onActionClick(e: OnActionClickParams<SystemApiApi.SystemApiItem>) {
|
||||
switch (e.code) {
|
||||
case 'delete': {
|
||||
onDelete(e.row);
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
onEdit(e.row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Antd的Modal.confirm封装为promise,方便在异步函数中调用。
|
||||
* @param content 提示内容
|
||||
* @param title 提示标题
|
||||
*/
|
||||
function confirm(content: string, title: string) {
|
||||
return new Promise((reslove, reject) => {
|
||||
Modal.confirm({
|
||||
content,
|
||||
onCancel() {
|
||||
reject(new Error('已取消'));
|
||||
},
|
||||
onOk() {
|
||||
reslove(true);
|
||||
},
|
||||
title,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态开关即将改变
|
||||
* @param newStatus 期望改变的状态值
|
||||
* @param row 行数据
|
||||
* @returns 返回false则中止改变,返回其他值(undefined、true)则允许改变
|
||||
*/
|
||||
async function onStatusChange(
|
||||
newStatus: number,
|
||||
row: SystemApiApi.SystemApiItem,
|
||||
) {
|
||||
const status: Recordable<string> = {
|
||||
0: '禁用',
|
||||
1: '启用',
|
||||
};
|
||||
try {
|
||||
await confirm(
|
||||
`你要将${row.api_name}的状态切换为 【${status[newStatus.toString()]}】 吗?`,
|
||||
`切换状态`,
|
||||
);
|
||||
await updateApi(row.id, {
|
||||
...row,
|
||||
status: newStatus as 0 | 1,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function onEdit(row: SystemApiApi.SystemApiItem) {
|
||||
formDrawerApi.setData(row).open();
|
||||
}
|
||||
|
||||
function onDelete(row: SystemApiApi.SystemApiItem) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.api_name]),
|
||||
duration: 0,
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
deleteApi(row.id)
|
||||
.then(() => {
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.api_name]),
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
onRefresh();
|
||||
})
|
||||
.catch(() => {
|
||||
hideLoading();
|
||||
});
|
||||
}
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
function onCreate() {
|
||||
formDrawerApi.setData({}).open();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormDrawer @success="onRefresh" />
|
||||
<Grid :table-title="$t('system.api.list')">
|
||||
<template #toolbar-tools>
|
||||
<Button type="primary" @click="onCreate">
|
||||
<Plus class="size-5" />
|
||||
{{ $t('ui.actionTitle.create', [$t('system.api.name')]) }}
|
||||
</Button>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
77
apps/web-antd/src/views/system/api/modules/form.vue
Normal file
77
apps/web-antd/src/views/system/api/modules/form.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SystemApiApi } from '#/api';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer, useVbenForm } from '@vben/common-ui';
|
||||
|
||||
import { createApi, updateApi } from '#/api';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: [];
|
||||
}>();
|
||||
|
||||
const formData = ref<SystemApiApi.SystemApiItem>();
|
||||
const schema = useFormSchema();
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
colon: true,
|
||||
formItemClass: 'col-span-2 md:col-span-1',
|
||||
},
|
||||
schema,
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'grid-cols-2 gap-x-4',
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
onConfirm: onSubmit,
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<SystemApiApi.SystemApiItem>();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
formApi.setValues(formData.value);
|
||||
} else {
|
||||
formApi.resetForm();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (valid) {
|
||||
drawerApi.lock();
|
||||
const data =
|
||||
await formApi.getValues<
|
||||
Omit<SystemApiApi.SystemApiItem, 'create_time' | 'id' | 'update_time'>
|
||||
>();
|
||||
|
||||
try {
|
||||
await (formData.value?.id
|
||||
? updateApi(formData.value.id, data)
|
||||
: createApi(data));
|
||||
drawerApi.close();
|
||||
emit('success');
|
||||
} finally {
|
||||
drawerApi.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getDrawerTitle = computed(() =>
|
||||
formData.value?.id
|
||||
? $t('ui.actionTitle.edit', [$t('system.api.name')])
|
||||
: $t('ui.actionTitle.create', [$t('system.api.name')]),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer class="w-full max-w-[600px]" :title="getDrawerTitle">
|
||||
<Form class="mx-4" />
|
||||
</Drawer>
|
||||
</template>
|
||||
135
apps/web-antd/src/views/system/dept/data.ts
Normal file
135
apps/web-antd/src/views/system/dept/data.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
|
||||
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn } from '#/adapter/vxe-table';
|
||||
import type { SystemDeptApi } from '#/api/system/dept';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getDeptList } from '#/api/system/dept';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
/**
|
||||
* 获取编辑表单的字段配置。如果没有使用多语言,可以直接export一个数组常量
|
||||
*/
|
||||
export function useSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: $t('system.dept.deptName'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(2, $t('ui.formRules.minLength', [$t('system.dept.deptName'), 2]))
|
||||
.max(
|
||||
20,
|
||||
$t('ui.formRules.maxLength', [$t('system.dept.deptName'), 20]),
|
||||
),
|
||||
},
|
||||
{
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
api: getDeptList,
|
||||
class: 'w-full',
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
childrenField: 'children',
|
||||
},
|
||||
fieldName: 'pid',
|
||||
label: $t('system.dept.parentDept'),
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
options: [
|
||||
{ label: $t('common.enabled'), value: 1 },
|
||||
{ label: $t('common.disabled'), value: 0 },
|
||||
],
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'status',
|
||||
label: $t('system.dept.status'),
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
maxLength: 50,
|
||||
rows: 3,
|
||||
showCount: true,
|
||||
},
|
||||
fieldName: 'remark',
|
||||
label: $t('system.dept.remark'),
|
||||
rules: z
|
||||
.string()
|
||||
.max(50, $t('ui.formRules.maxLength', [$t('system.dept.remark'), 50]))
|
||||
.optional(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表格列配置
|
||||
* @description 使用函数的形式返回列数据而不是直接export一个Array常量,是为了响应语言切换时重新翻译表头
|
||||
* @param onActionClick 表格操作按钮点击事件
|
||||
*/
|
||||
export function useColumns(
|
||||
onActionClick?: OnActionClickFn<SystemDeptApi.SystemDept>,
|
||||
): VxeTableGridOptions<SystemDeptApi.SystemDept>['columns'] {
|
||||
return [
|
||||
{
|
||||
align: 'left',
|
||||
field: 'name',
|
||||
fixed: 'left',
|
||||
title: $t('system.dept.deptName'),
|
||||
treeNode: true,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
cellRender: { name: 'CellTag' },
|
||||
field: 'status',
|
||||
title: $t('system.dept.status'),
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: $t('system.dept.createTime'),
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
field: 'remark',
|
||||
title: $t('system.dept.remark'),
|
||||
},
|
||||
{
|
||||
align: 'right',
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'name',
|
||||
nameTitle: $t('system.dept.name'),
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'append',
|
||||
text: '新增下级',
|
||||
},
|
||||
'edit', // 默认的编辑按钮
|
||||
{
|
||||
code: 'delete', // 默认的删除按钮
|
||||
disabled: (row: SystemDeptApi.SystemDept) => {
|
||||
return !!(row.children && row.children.length > 0);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
headerAlign: 'center',
|
||||
showOverflow: false,
|
||||
title: $t('system.dept.operation'),
|
||||
width: 200,
|
||||
},
|
||||
];
|
||||
}
|
||||
143
apps/web-antd/src/views/system/dept/list.vue
Normal file
143
apps/web-antd/src/views/system/dept/list.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { SystemDeptApi } from '#/api/system/dept';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { Plus } from '@vben/icons';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteDept, getDeptList } from '#/api/system/dept';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useColumns } from './data';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* 编辑部门
|
||||
* @param row
|
||||
*/
|
||||
function onEdit(row: SystemDeptApi.SystemDept) {
|
||||
formModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加下级部门
|
||||
* @param row
|
||||
*/
|
||||
function onAppend(row: SystemDeptApi.SystemDept) {
|
||||
formModalApi.setData({ pid: row.id }).open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新部门
|
||||
*/
|
||||
function onCreate() {
|
||||
formModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除部门
|
||||
* @param row
|
||||
*/
|
||||
function onDelete(row: SystemDeptApi.SystemDept) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
deleteDept(row.id)
|
||||
.then(() => {
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
refreshGrid();
|
||||
})
|
||||
.catch(() => {
|
||||
hideLoading();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格操作按钮的回调函数
|
||||
*/
|
||||
function onActionClick({
|
||||
code,
|
||||
row,
|
||||
}: OnActionClickParams<SystemDeptApi.SystemDept>) {
|
||||
switch (code) {
|
||||
case 'append': {
|
||||
onAppend(row);
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
onDelete(row);
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
onEdit(row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridEvents: {},
|
||||
gridOptions: {
|
||||
columns: useColumns(onActionClick),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async (_params) => {
|
||||
return await getDeptList();
|
||||
},
|
||||
},
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
zoom: true,
|
||||
},
|
||||
treeConfig: {
|
||||
parentField: 'pid',
|
||||
rowField: 'id',
|
||||
transform: false,
|
||||
},
|
||||
} as VxeTableGridOptions,
|
||||
});
|
||||
|
||||
/**
|
||||
* 刷新表格
|
||||
*/
|
||||
function refreshGrid() {
|
||||
gridApi.query();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="refreshGrid" />
|
||||
<Grid table-title="部门列表">
|
||||
<template #toolbar-tools>
|
||||
<Button type="primary" @click="onCreate">
|
||||
<Plus class="size-5" />
|
||||
{{ $t('ui.actionTitle.create', [$t('system.dept.name')]) }}
|
||||
</Button>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
78
apps/web-antd/src/views/system/dept/modules/form.vue
Normal file
78
apps/web-antd/src/views/system/dept/modules/form.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SystemDeptApi } from '#/api/system/dept';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createDept, updateDept } from '#/api/system/dept';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<SystemDeptApi.SystemDept>();
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', [$t('system.dept.name')])
|
||||
: $t('ui.actionTitle.create', [$t('system.dept.name')]);
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
layout: 'vertical',
|
||||
schema: useSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
formApi.resetForm();
|
||||
formApi.setValues(formData.value || {});
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (valid) {
|
||||
modalApi.lock();
|
||||
const data = await formApi.getValues();
|
||||
try {
|
||||
await (formData.value?.id
|
||||
? updateDept(formData.value.id, data)
|
||||
: createDept(data));
|
||||
modalApi.close();
|
||||
emit('success');
|
||||
} finally {
|
||||
modalApi.lock(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<SystemDeptApi.SystemDept>();
|
||||
if (data) {
|
||||
if (data.pid === 0) {
|
||||
data.pid = undefined;
|
||||
}
|
||||
formData.value = data;
|
||||
formApi.setValues(formData.value);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getTitle">
|
||||
<Form class="mx-4" />
|
||||
<template #prepend-footer>
|
||||
<div class="flex-auto">
|
||||
<Button type="primary" danger @click="resetForm">
|
||||
{{ $t('common.reset') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
109
apps/web-antd/src/views/system/menu/data.ts
Normal file
109
apps/web-antd/src/views/system/menu/data.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { SystemMenuApi } from '#/api/system/menu';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
export function getMenuTypeOptions() {
|
||||
return [
|
||||
{
|
||||
color: 'processing',
|
||||
label: $t('system.menu.typeCatalog'),
|
||||
value: 'catalog',
|
||||
},
|
||||
{ color: 'default', label: $t('system.menu.typeMenu'), value: 'menu' },
|
||||
{ color: 'error', label: $t('system.menu.typeButton'), value: 'action' },
|
||||
{
|
||||
color: 'success',
|
||||
label: $t('system.menu.typeEmbedded'),
|
||||
value: 'embedded',
|
||||
},
|
||||
{ color: 'warning', label: $t('system.menu.typeLink'), value: 'link' },
|
||||
];
|
||||
}
|
||||
|
||||
export function useColumns(
|
||||
onActionClick: OnActionClickFn<SystemMenuApi.SystemMenu>,
|
||||
): VxeTableGridOptions<SystemMenuApi.SystemMenu>['columns'] {
|
||||
return [
|
||||
{
|
||||
align: 'left',
|
||||
field: 'meta.title',
|
||||
fixed: 'left',
|
||||
slots: { default: 'title' },
|
||||
title: $t('system.menu.menuTitle'),
|
||||
treeNode: true,
|
||||
width: 250,
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
cellRender: { name: 'CellTag', options: getMenuTypeOptions() },
|
||||
field: 'type',
|
||||
title: $t('system.menu.type'),
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'authCode',
|
||||
title: $t('system.menu.authCode'),
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
align: 'left',
|
||||
field: 'path',
|
||||
title: $t('system.menu.path'),
|
||||
width: 200,
|
||||
},
|
||||
|
||||
{
|
||||
align: 'left',
|
||||
field: 'component',
|
||||
formatter: ({ row }) => {
|
||||
switch (row.type) {
|
||||
case 'catalog':
|
||||
case 'menu': {
|
||||
return row.component ?? '';
|
||||
}
|
||||
case 'embedded': {
|
||||
return row.meta?.iframeSrc ?? '';
|
||||
}
|
||||
case 'link': {
|
||||
return row.meta?.link ?? '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
minWidth: 200,
|
||||
title: $t('system.menu.component'),
|
||||
},
|
||||
{
|
||||
cellRender: { name: 'CellTag' },
|
||||
field: 'status',
|
||||
title: $t('system.menu.status'),
|
||||
width: 100,
|
||||
},
|
||||
|
||||
{
|
||||
align: 'right',
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'name',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'append',
|
||||
text: '新增下级',
|
||||
},
|
||||
'edit', // 默认的编辑按钮
|
||||
'delete', // 默认的删除按钮
|
||||
],
|
||||
},
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
headerAlign: 'center',
|
||||
showOverflow: false,
|
||||
title: $t('system.menu.operation'),
|
||||
width: 200,
|
||||
},
|
||||
];
|
||||
}
|
||||
162
apps/web-antd/src/views/system/menu/list.vue
Normal file
162
apps/web-antd/src/views/system/menu/list.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
import { IconifyIcon, Plus } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { MenuBadge } from '@vben-core/menu-ui';
|
||||
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteMenu, getMenuList, SystemMenuApi } from '#/api/system/menu';
|
||||
|
||||
import { useColumns } from './data';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: useColumns(onActionClick),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async (_params) => {
|
||||
return await getMenuList();
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
zoom: true,
|
||||
},
|
||||
treeConfig: {
|
||||
parentField: 'pid',
|
||||
rowField: 'id',
|
||||
transform: false,
|
||||
},
|
||||
} as VxeTableGridOptions,
|
||||
});
|
||||
|
||||
function onActionClick({
|
||||
code,
|
||||
row,
|
||||
}: OnActionClickParams<SystemMenuApi.SystemMenu>) {
|
||||
switch (code) {
|
||||
case 'append': {
|
||||
onAppend(row);
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
onDelete(row);
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
onEdit(row);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
function onEdit(row: SystemMenuApi.SystemMenu) {
|
||||
formDrawerApi.setData(row).open();
|
||||
}
|
||||
function onCreate() {
|
||||
formDrawerApi.setData({}).open();
|
||||
}
|
||||
function onAppend(row: SystemMenuApi.SystemMenu) {
|
||||
formDrawerApi.setData({ pid: row.id }).open();
|
||||
}
|
||||
|
||||
function onDelete(row: SystemMenuApi.SystemMenu) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
deleteMenu(row.id)
|
||||
.then(() => {
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
onRefresh();
|
||||
})
|
||||
.catch(() => {
|
||||
hideLoading();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormDrawer @success="onRefresh" />
|
||||
<Grid>
|
||||
<template #toolbar-tools>
|
||||
<Button type="primary" @click="onCreate">
|
||||
<Plus class="size-5" />
|
||||
{{ $t('ui.actionTitle.create', [$t('system.menu.name')]) }}
|
||||
</Button>
|
||||
</template>
|
||||
<template #title="{ row }">
|
||||
<div class="flex w-full items-center gap-1">
|
||||
<div class="size-5 flex-shrink-0">
|
||||
<IconifyIcon
|
||||
v-if="row.type === 'button'"
|
||||
icon="carbon:security"
|
||||
class="size-full"
|
||||
/>
|
||||
<IconifyIcon
|
||||
v-else-if="row.meta?.icon"
|
||||
:icon="row.meta?.icon || 'carbon:circle-dash'"
|
||||
class="size-full"
|
||||
/>
|
||||
</div>
|
||||
<span class="flex-auto">{{ $t(row.meta?.title) }}</span>
|
||||
<div class="items-center justify-end"></div>
|
||||
</div>
|
||||
<MenuBadge
|
||||
v-if="row.meta?.badgeType"
|
||||
class="menu-badge"
|
||||
:badge="row.meta.badge"
|
||||
:badge-type="row.meta.badgeType"
|
||||
:badge-variants="row.meta.badgeVariants"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.menu-badge {
|
||||
top: 50%;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
|
||||
& > :deep(div) {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
503
apps/web-antd/src/views/system/menu/modules/form.vue
Normal file
503
apps/web-antd/src/views/system/menu/modules/form.vue
Normal file
@@ -0,0 +1,503 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ChangeEvent } from 'ant-design-vue/es/_util/EventInterface';
|
||||
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
import { computed, h, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { $te } from '@vben/locales';
|
||||
import { getPopupContainer } from '@vben/utils';
|
||||
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core';
|
||||
|
||||
import { useVbenForm, z } from '#/adapter/form';
|
||||
import {
|
||||
createMenu,
|
||||
getMenuList,
|
||||
SystemMenuApi,
|
||||
updateMenu,
|
||||
} from '#/api/system/menu';
|
||||
import { $t } from '#/locales';
|
||||
import { componentKeys } from '#/router/routes';
|
||||
|
||||
import { getMenuTypeOptions } from '../data';
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: [];
|
||||
}>();
|
||||
const formData = ref<SystemMenuApi.SystemMenu>();
|
||||
const titleSuffix = ref<string>();
|
||||
const schema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
options: getMenuTypeOptions(),
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 'menu',
|
||||
fieldName: 'type',
|
||||
formItemClass: 'col-span-2 md:col-span-2',
|
||||
label: $t('system.menu.type'),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: $t('system.menu.menuName'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(2, $t('ui.formRules.minLength', [$t('system.menu.menuName'), 2]))
|
||||
.max(30, $t('ui.formRules.maxLength', [$t('system.menu.menuName'), 30])),
|
||||
// .refine(
|
||||
// async (value: string) => {
|
||||
// return !(await isMenuNameExists(value, formData.value?.id));
|
||||
// },
|
||||
// (value) => ({
|
||||
// message: $t('ui.formRules.alreadyExists', [
|
||||
// $t('system.menu.menuName'),
|
||||
// value,
|
||||
// ]),
|
||||
// }),
|
||||
// ),
|
||||
},
|
||||
{
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
api: getMenuList,
|
||||
class: 'w-full',
|
||||
filterTreeNode(input: string, node: Recordable<any>) {
|
||||
if (!input || input.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const title: string = node.meta?.title ?? '';
|
||||
if (!title) return false;
|
||||
return title.includes(input) || $t(title).includes(input);
|
||||
},
|
||||
getPopupContainer,
|
||||
labelField: 'meta.title',
|
||||
showSearch: true,
|
||||
treeDefaultExpandAll: true,
|
||||
valueField: 'id',
|
||||
childrenField: 'children',
|
||||
},
|
||||
fieldName: 'pid',
|
||||
label: $t('system.menu.parent'),
|
||||
renderComponentContent() {
|
||||
return {
|
||||
title({ label, meta }: { label: string; meta: Recordable<any> }) {
|
||||
const coms = [];
|
||||
if (!label) return '';
|
||||
if (meta?.icon) {
|
||||
coms.push(h(IconifyIcon, { class: 'size-4', icon: meta.icon }));
|
||||
}
|
||||
coms.push(h('span', { class: '' }, $t(label || '')));
|
||||
return h('div', { class: 'flex items-center gap-1' }, coms);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
componentProps() {
|
||||
// 不需要处理多语言时就无需这么做
|
||||
return {
|
||||
addonAfter: titleSuffix.value,
|
||||
onChange({ target: { value } }: ChangeEvent) {
|
||||
titleSuffix.value = value && $te(value) ? $t(value) : undefined;
|
||||
},
|
||||
};
|
||||
},
|
||||
fieldName: 'meta.title',
|
||||
label: $t('system.menu.menuTitle'),
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return ['catalog', 'embedded', 'menu'].includes(values.type);
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'path',
|
||||
label: $t('system.menu.path'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(2, $t('ui.formRules.minLength', [$t('system.menu.path'), 2]))
|
||||
.max(100, $t('ui.formRules.maxLength', [$t('system.menu.path'), 100]))
|
||||
.refine(
|
||||
(value: string) => {
|
||||
return value.startsWith('/');
|
||||
},
|
||||
$t('ui.formRules.startWith', [$t('system.menu.path'), '/']),
|
||||
),
|
||||
// .refine(
|
||||
// async (value: string) => {
|
||||
// return !(await isMenuPathExists(value, formData.value?.id));
|
||||
// },
|
||||
// (value) => ({
|
||||
// message: $t('ui.formRules.alreadyExists', [
|
||||
// $t('system.menu.path'),
|
||||
// value,
|
||||
// ]),
|
||||
// }),
|
||||
// ),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return ['embedded', 'menu'].includes(values.type);
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'activePath',
|
||||
help: $t('system.menu.activePathHelp'),
|
||||
label: $t('system.menu.activePath'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(2, $t('ui.formRules.minLength', [$t('system.menu.path'), 2]))
|
||||
.max(100, $t('ui.formRules.maxLength', [$t('system.menu.path'), 100]))
|
||||
.refine(
|
||||
(value: string) => {
|
||||
return value.startsWith('/');
|
||||
},
|
||||
$t('ui.formRules.startWith', [$t('system.menu.path'), '/']),
|
||||
)
|
||||
// .refine(async (value: string) => {
|
||||
// return await isMenuPathExists(value, formData.value?.id);
|
||||
// }, $t('system.menu.activePathMustExist'))
|
||||
.optional(),
|
||||
},
|
||||
{
|
||||
component: 'IconPicker',
|
||||
componentProps: {
|
||||
prefix: 'carbon',
|
||||
},
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return ['catalog', 'embedded', 'link', 'menu'].includes(values.type);
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'meta.icon',
|
||||
label: $t('system.menu.icon'),
|
||||
},
|
||||
{
|
||||
component: 'IconPicker',
|
||||
componentProps: {
|
||||
prefix: 'carbon',
|
||||
},
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return ['catalog', 'embedded', 'menu'].includes(values.type);
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'meta.activeIcon',
|
||||
label: $t('system.menu.activeIcon'),
|
||||
},
|
||||
{
|
||||
component: 'AutoComplete',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
class: 'w-full',
|
||||
filterOption(input: string, option: { value: string }) {
|
||||
return option.value.toLowerCase().includes(input.toLowerCase());
|
||||
},
|
||||
options: componentKeys.map((v) => ({ value: v })),
|
||||
},
|
||||
dependencies: {
|
||||
rules: (values) => {
|
||||
return values.type === 'menu' ? 'required' : null;
|
||||
},
|
||||
show: (values) => {
|
||||
return values.type === 'menu';
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'component',
|
||||
label: $t('system.menu.component'),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return ['embedded', 'link'].includes(values.type);
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'linkSrc',
|
||||
label: $t('system.menu.linkSrc'),
|
||||
rules: z.string().url($t('ui.formRules.invalidURL')),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
rules: (values) => {
|
||||
return values.type === 'action' ? 'required' : null;
|
||||
},
|
||||
show: (values) => {
|
||||
return ['action', 'catalog', 'embedded', 'menu'].includes(values.type);
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'authCode',
|
||||
label: $t('system.menu.authCode'),
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
options: [
|
||||
{ label: $t('common.enabled'), value: 1 },
|
||||
{ label: $t('common.disabled'), value: 0 },
|
||||
],
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'status',
|
||||
label: $t('system.menu.status'),
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
class: 'w-full',
|
||||
options: [
|
||||
{ label: $t('system.menu.badgeType.dot'), value: 'dot' },
|
||||
{ label: $t('system.menu.badgeType.normal'), value: 'normal' },
|
||||
],
|
||||
},
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return values.type !== 'action';
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'meta.badgeType',
|
||||
label: $t('system.menu.badgeType.title'),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
componentProps: (values) => {
|
||||
return {
|
||||
allowClear: true,
|
||||
class: 'w-full',
|
||||
disabled: values.meta?.badgeType !== 'normal',
|
||||
};
|
||||
},
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return values.type !== 'action';
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'meta.badge',
|
||||
label: $t('system.menu.badge'),
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
class: 'w-full',
|
||||
options: SystemMenuApi.BadgeVariants.map((v) => ({
|
||||
label: v,
|
||||
value: v,
|
||||
})),
|
||||
},
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return values.type !== 'action';
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'meta.badgeVariants',
|
||||
label: $t('system.menu.badgeVariants'),
|
||||
},
|
||||
{
|
||||
component: 'Divider',
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return !['action', 'link'].includes(values.type);
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'divider1',
|
||||
formItemClass: 'col-span-2 md:col-span-2 pb-0',
|
||||
hideLabel: true,
|
||||
renderComponentContent() {
|
||||
return {
|
||||
default: () => $t('system.menu.advancedSettings'),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Checkbox',
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return ['menu'].includes(values.type);
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'meta.keepAlive',
|
||||
renderComponentContent() {
|
||||
return {
|
||||
default: () => $t('system.menu.keepAlive'),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Checkbox',
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return ['embedded', 'menu'].includes(values.type);
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'meta.affixTab',
|
||||
renderComponentContent() {
|
||||
return {
|
||||
default: () => $t('system.menu.affixTab'),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Checkbox',
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return !['action'].includes(values.type);
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'meta.hideInMenu',
|
||||
renderComponentContent() {
|
||||
return {
|
||||
default: () => $t('system.menu.hideInMenu'),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Checkbox',
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return ['catalog', 'menu'].includes(values.type);
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'meta.hideChildrenInMenu',
|
||||
renderComponentContent() {
|
||||
return {
|
||||
default: () => $t('system.menu.hideChildrenInMenu'),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Checkbox',
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return !['action', 'link'].includes(values.type);
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'meta.hideInBreadcrumb',
|
||||
renderComponentContent() {
|
||||
return {
|
||||
default: () => $t('system.menu.hideInBreadcrumb'),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Checkbox',
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return !['action', 'link'].includes(values.type);
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'meta.hideInTab',
|
||||
renderComponentContent() {
|
||||
return {
|
||||
default: () => $t('system.menu.hideInTab'),
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind);
|
||||
const isHorizontal = computed(() => breakpoints.greaterOrEqual('md').value);
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
colon: true,
|
||||
formItemClass: 'col-span-2 md:col-span-1',
|
||||
},
|
||||
schema,
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'grid-cols-2 gap-x-4',
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
onConfirm: onSubmit,
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<SystemMenuApi.SystemMenu>();
|
||||
if (data?.type === 'link') {
|
||||
data.linkSrc = data.meta?.link;
|
||||
} else if (data?.type === 'embedded') {
|
||||
data.linkSrc = data.meta?.iframeSrc;
|
||||
}
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
formApi.setValues(formData.value);
|
||||
titleSuffix.value = formData.value.meta?.title
|
||||
? $t(formData.value.meta.title)
|
||||
: '';
|
||||
} else {
|
||||
formApi.resetForm();
|
||||
titleSuffix.value = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (valid) {
|
||||
drawerApi.lock();
|
||||
const data =
|
||||
await formApi.getValues<
|
||||
Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>
|
||||
>();
|
||||
if (data.type === 'link') {
|
||||
data.meta = { ...data.meta, link: data.linkSrc };
|
||||
} else if (data.type === 'embedded') {
|
||||
data.meta = { ...data.meta, iframeSrc: data.linkSrc };
|
||||
}
|
||||
delete data.linkSrc;
|
||||
try {
|
||||
await (formData.value?.id
|
||||
? updateMenu(formData.value.id, data)
|
||||
: createMenu(data));
|
||||
drawerApi.close();
|
||||
emit('success');
|
||||
} finally {
|
||||
drawerApi.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
const getDrawerTitle = computed(() =>
|
||||
formData.value?.id
|
||||
? $t('ui.actionTitle.edit', [$t('system.menu.name')])
|
||||
: $t('ui.actionTitle.create', [$t('system.menu.name')]),
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<Drawer class="w-full max-w-[800px]" :title="getDrawerTitle">
|
||||
<Form class="mx-4" :layout="isHorizontal ? 'horizontal' : 'vertical'" />
|
||||
</Drawer>
|
||||
</template>
|
||||
148
apps/web-antd/src/views/system/role/data.ts
Normal file
148
apps/web-antd/src/views/system/role/data.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { SystemRoleApi } from '#/api';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'role_name',
|
||||
label: $t('system.role.roleName'),
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'role_code',
|
||||
label: $t('system.role.roleCode'),
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
options: [
|
||||
{ label: $t('common.enabled'), value: 1 },
|
||||
{ label: $t('common.disabled'), value: 0 },
|
||||
],
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'status',
|
||||
label: $t('system.role.status'),
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'description',
|
||||
label: $t('system.role.remark'),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'menu_ids',
|
||||
formItemClass: 'items-start',
|
||||
label: $t('system.role.setPermissions'),
|
||||
modelPropName: 'modelValue',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'role_name',
|
||||
label: $t('system.role.roleName'),
|
||||
},
|
||||
{ component: 'Input', fieldName: 'id', label: $t('system.role.id') },
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: $t('common.enabled'), value: 1 },
|
||||
{ label: $t('common.disabled'), value: 0 },
|
||||
],
|
||||
},
|
||||
fieldName: 'status',
|
||||
label: $t('system.role.status'),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'description',
|
||||
label: $t('system.role.remark'),
|
||||
},
|
||||
{
|
||||
component: 'RangePicker',
|
||||
fieldName: 'create_time',
|
||||
label: $t('system.role.createTime'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useColumns<T = SystemRoleApi.SystemRoleItem>(
|
||||
onActionClick: OnActionClickFn<T>,
|
||||
onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'role_name',
|
||||
title: $t('system.role.roleName'),
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
field: 'role_code',
|
||||
title: $t('system.role.roleCode'),
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
cellRender: {
|
||||
attrs: { beforeChange: onStatusChange },
|
||||
name: onStatusChange ? 'CellSwitch' : 'CellTag',
|
||||
},
|
||||
field: 'status',
|
||||
title: $t('system.role.status'),
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
minWidth: 100,
|
||||
title: $t('system.role.remark'),
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: $t('system.role.createTime'),
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'role_name',
|
||||
nameTitle: $t('system.role.roleName'),
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'edit',
|
||||
text: $t('ui.actionTitle.edit', [$t('system.role.name')]),
|
||||
},
|
||||
{
|
||||
code: 'api-permissions',
|
||||
text: $t('system.role.setApiPermissions'),
|
||||
},
|
||||
{
|
||||
code: 'delete',
|
||||
text: $t('ui.actionTitle.delete', [$t('system.role.name')]),
|
||||
danger: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
title: $t('system.role.operation'),
|
||||
width: 200,
|
||||
},
|
||||
];
|
||||
}
|
||||
179
apps/web-antd/src/views/system/role/list.vue
Normal file
179
apps/web-antd/src/views/system/role/list.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { SystemRoleApi } from '#/api';
|
||||
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
import { Plus } from '@vben/icons';
|
||||
|
||||
import { Button, message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteRole, getRoleList, updateRole } from '#/api';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useColumns, useGridFormSchema } from './data';
|
||||
import ApiPermissions from './modules/api-permissions.vue';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [ApiPermissionsDrawer, apiPermissionsDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: ApiPermissions,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
fieldMappingTime: [['create_time', ['startTime', 'endTime']]],
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useColumns(onActionClick, onStatusChange),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getRoleList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
} as VxeTableGridOptions<SystemRoleApi.SystemRoleItem>,
|
||||
});
|
||||
|
||||
function onActionClick(e: OnActionClickParams<SystemRoleApi.SystemRoleItem>) {
|
||||
switch (e.code) {
|
||||
case 'api-permissions': {
|
||||
onApiPermissions(e.row);
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
onDelete(e.row);
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
onEdit(e.row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Antd的Modal.confirm封装为promise,方便在异步函数中调用。
|
||||
* @param content 提示内容
|
||||
* @param title 提示标题
|
||||
*/
|
||||
function confirm(content: string, title: string) {
|
||||
return new Promise((reslove, reject) => {
|
||||
Modal.confirm({
|
||||
content,
|
||||
onCancel() {
|
||||
reject(new Error('已取消'));
|
||||
},
|
||||
onOk() {
|
||||
reslove(true);
|
||||
},
|
||||
title,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态开关即将改变
|
||||
* @param newStatus 期望改变的状态值
|
||||
* @param row 行数据
|
||||
* @returns 返回false则中止改变,返回其他值(undefined、true)则允许改变
|
||||
*/
|
||||
async function onStatusChange(
|
||||
newStatus: number,
|
||||
row: SystemRoleApi.SystemRoleItem,
|
||||
) {
|
||||
const status: Recordable<string> = {
|
||||
0: '禁用',
|
||||
1: '启用',
|
||||
};
|
||||
try {
|
||||
await confirm(
|
||||
`你要将${row.role_name}的状态切换为 【${status[newStatus.toString()]}】 吗?`,
|
||||
`切换状态`,
|
||||
);
|
||||
await updateRole(row.id, { status: newStatus });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function onEdit(row: SystemRoleApi.SystemRoleItem) {
|
||||
formDrawerApi.setData(row).open();
|
||||
}
|
||||
|
||||
function onDelete(row: SystemRoleApi.SystemRoleItem) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.role_name]),
|
||||
duration: 0,
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
deleteRole(row.id)
|
||||
.then(() => {
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.role_name]),
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
onRefresh();
|
||||
})
|
||||
.catch(() => {
|
||||
hideLoading();
|
||||
});
|
||||
}
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
function onCreate() {
|
||||
formDrawerApi.setData({}).open();
|
||||
}
|
||||
|
||||
function onApiPermissions(row: SystemRoleApi.SystemRoleItem) {
|
||||
apiPermissionsDrawerApi.setData(row).open();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormDrawer @success="onRefresh" />
|
||||
<ApiPermissionsDrawer @success="onRefresh" />
|
||||
<Grid :table-title="$t('system.role.list')">
|
||||
<template #toolbar-tools>
|
||||
<Button type="primary" @click="onCreate">
|
||||
<Plus class="size-5" />
|
||||
{{ $t('ui.actionTitle.create', [$t('system.role.name')]) }}
|
||||
</Button>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
214
apps/web-antd/src/views/system/role/modules/api-permissions.vue
Normal file
214
apps/web-antd/src/views/system/role/modules/api-permissions.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SystemRoleApi } from '#/api';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Button, Checkbox, message, Spin } from 'ant-design-vue';
|
||||
|
||||
import { getAllApiList, getRoleApiList, updateRoleApi } from '#/api';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const emits = defineEmits(['success']);
|
||||
|
||||
const loading = ref(false);
|
||||
const allApiList = ref<any[]>([]);
|
||||
const roleApiList = ref<any[]>([]);
|
||||
const selectedApiIds = ref<number[]>([]);
|
||||
const formData = ref<SystemRoleApi.SystemRoleItem>();
|
||||
const roleId = ref<number>();
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onConfirm() {
|
||||
await onSave();
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<SystemRoleApi.SystemRoleItem>();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
roleId.value = data.id;
|
||||
fetchRoleApiList();
|
||||
}
|
||||
fetchAllApiList();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 计算已选中的API ID
|
||||
const selectedApiIdsSet = computed(() => new Set(selectedApiIds.value));
|
||||
|
||||
// 获取所有API列表
|
||||
async function fetchAllApiList() {
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await getAllApiList({ status: 1 }); // 只获取启用的API
|
||||
allApiList.value = response.items || [];
|
||||
} catch (error) {
|
||||
console.error('获取API列表失败:', error);
|
||||
message.error('获取API列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取角色已分配的API权限
|
||||
async function fetchRoleApiList() {
|
||||
if (!roleId.value) return;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await getRoleApiList(roleId.value);
|
||||
roleApiList.value = response.items || [];
|
||||
selectedApiIds.value = roleApiList.value.map((item) => item.api_id);
|
||||
} catch (error) {
|
||||
console.error('获取角色API权限失败:', error);
|
||||
message.error('获取角色API权限失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选
|
||||
function toggleSelectAll(e: any) {
|
||||
const checked = e.target.checked;
|
||||
selectedApiIds.value = checked
|
||||
? allApiList.value.map((api) => api.api_id)
|
||||
: [];
|
||||
}
|
||||
|
||||
// 判断是否全选
|
||||
const isAllSelected = computed(() => {
|
||||
return (
|
||||
allApiList.value.length > 0 &&
|
||||
selectedApiIds.value.length === allApiList.value.length
|
||||
);
|
||||
});
|
||||
|
||||
// 判断是否部分选中
|
||||
const isIndeterminate = computed(() => {
|
||||
return (
|
||||
selectedApiIds.value.length > 0 &&
|
||||
selectedApiIds.value.length < allApiList.value.length
|
||||
);
|
||||
});
|
||||
|
||||
// 保存API权限
|
||||
async function onSave() {
|
||||
if (!roleId.value) return;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
await updateRoleApi({
|
||||
role_id: roleId.value,
|
||||
api_ids: selectedApiIds.value,
|
||||
});
|
||||
message.success('API权限保存成功');
|
||||
emits('success');
|
||||
drawerApi.close();
|
||||
} catch (error) {
|
||||
console.error('保存API权限失败:', error);
|
||||
message.error('保存API权限失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 重置选择
|
||||
function onReset() {
|
||||
selectedApiIds.value = roleApiList.value.map((item) => item.api_id);
|
||||
}
|
||||
|
||||
// 计算抽屉标题
|
||||
const getDrawerTitle = computed(() => {
|
||||
return formData.value?.role_name
|
||||
? `${$t('system.role.setApiPermissions')} - ${formData.value.role_name}`
|
||||
: $t('system.role.setApiPermissions');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer :title="getDrawerTitle">
|
||||
<div class="p-4">
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-gray-500">
|
||||
为角色分配API访问权限,勾选的API将被允许访问
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Spin :spinning="loading">
|
||||
<div class="space-y-4">
|
||||
<!-- 全选操作 -->
|
||||
<div class="flex items-center gap-4 rounded-lg bg-gray-50 p-3">
|
||||
<Checkbox
|
||||
:checked="isAllSelected"
|
||||
:indeterminate="isIndeterminate"
|
||||
@change="toggleSelectAll"
|
||||
>
|
||||
全选
|
||||
</Checkbox>
|
||||
<span class="text-sm text-gray-500">
|
||||
已选择 {{ selectedApiIds.length }} / {{ allApiList.length }} 个API
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- API列表 -->
|
||||
<div class="max-h-96 overflow-y-auto rounded-lg border">
|
||||
<div
|
||||
v-for="api in allApiList"
|
||||
:key="api.api_id"
|
||||
class="flex items-center gap-3 border-b p-3 last:border-b-0 hover:bg-gray-50"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="selectedApiIdsSet.has(api.api_id)"
|
||||
@change="
|
||||
(e) => {
|
||||
if (e.target.checked) {
|
||||
selectedApiIds.push(api.api_id);
|
||||
} else {
|
||||
const index = selectedApiIds.indexOf(api.api_id);
|
||||
if (index > -1) {
|
||||
selectedApiIds.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ api.api_name }}</span>
|
||||
<span
|
||||
class="rounded px-2 py-1 text-xs"
|
||||
:class="{
|
||||
'bg-blue-100 text-blue-800': api.method === 'GET',
|
||||
'bg-green-100 text-green-800': api.method === 'POST',
|
||||
'bg-orange-100 text-orange-800': api.method === 'PUT',
|
||||
'bg-red-100 text-red-800': api.method === 'DELETE',
|
||||
}"
|
||||
>
|
||||
{{ api.method }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-500">
|
||||
{{ api.url }}
|
||||
</div>
|
||||
<div v-if="api.description" class="mt-1 text-xs text-gray-400">
|
||||
{{ api.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<Button @click="onReset"> 重置 </Button>
|
||||
<Button type="primary" @click="onSave" :loading="loading">
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</template>
|
||||
139
apps/web-antd/src/views/system/role/modules/form.vue
Normal file
139
apps/web-antd/src/views/system/role/modules/form.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DataNode } from 'ant-design-vue/es/tree';
|
||||
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type { SystemRoleApi } from '#/api/system/role';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer, VbenTree } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { Spin } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { getMenuList } from '#/api/system/menu';
|
||||
import { createRole, updateRole } from '#/api/system/role';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emits = defineEmits(['success']);
|
||||
|
||||
const formData = ref<SystemRoleApi.SystemRole>();
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const permissions = ref<DataNode[]>([]);
|
||||
const loadingPermissions = ref(false);
|
||||
|
||||
const id = ref();
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
const values = await formApi.getValues();
|
||||
drawerApi.lock();
|
||||
(id.value ? updateRole(id.value, values) : createRole(values))
|
||||
.then(() => {
|
||||
emits('success');
|
||||
drawerApi.close();
|
||||
})
|
||||
.catch(() => {
|
||||
drawerApi.unlock();
|
||||
});
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<SystemRoleApi.SystemRole>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
id.value = data.id;
|
||||
formApi.setValues(data);
|
||||
} else {
|
||||
id.value = undefined;
|
||||
}
|
||||
|
||||
if (permissions.value.length === 0) {
|
||||
loadPermissions();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function loadPermissions() {
|
||||
loadingPermissions.value = true;
|
||||
try {
|
||||
const res = await getMenuList();
|
||||
permissions.value = res as unknown as DataNode[];
|
||||
} finally {
|
||||
loadingPermissions.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const getDrawerTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('common.edit', $t('system.role.name'))
|
||||
: $t('common.create', $t('system.role.name'));
|
||||
});
|
||||
|
||||
function getNodeClass(node: Recordable<any>) {
|
||||
const classes: string[] = [];
|
||||
if (node.value?.type === 'button') {
|
||||
classes.push('inline-flex');
|
||||
if (node.index % 3 >= 1) {
|
||||
classes.push('!pl-0');
|
||||
}
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Drawer :title="getDrawerTitle">
|
||||
<Form>
|
||||
<template #menu_ids="slotProps">
|
||||
<Spin :spinning="loadingPermissions" wrapper-class-name="w-full">
|
||||
<VbenTree
|
||||
:tree-data="permissions"
|
||||
multiple
|
||||
bordered
|
||||
:default-expanded-level="2"
|
||||
:get-node-class="getNodeClass"
|
||||
v-bind="slotProps"
|
||||
value-field="id"
|
||||
label-field="meta.title"
|
||||
icon-field="meta.icon"
|
||||
>
|
||||
<template #node="{ value }">
|
||||
<IconifyIcon v-if="value.meta.icon" :icon="value.meta.icon" />
|
||||
{{ $t(value.meta.title) }}
|
||||
</template>
|
||||
</VbenTree>
|
||||
</Spin>
|
||||
</template>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</template>
|
||||
<style lang="css" scoped>
|
||||
:deep(.ant-tree-title) {
|
||||
.tree-actions {
|
||||
display: none;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-tree-title:hover) {
|
||||
.tree-actions {
|
||||
display: flex;
|
||||
flex: auto;
|
||||
justify-content: flex-end;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
137
apps/web-antd/src/views/system/user/data.ts
Normal file
137
apps/web-antd/src/views/system/user/data.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { SystemUserApi } from '#/api';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
// 角色列表引用,可以在加载后更新
|
||||
export const roleOptions = ref<{ label: string; value: number | string }[]>([]);
|
||||
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'username',
|
||||
label: $t('system.user.userName'),
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'real_name',
|
||||
label: $t('system.user.realName'),
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
options: [
|
||||
{ label: $t('common.enabled'), value: 1 },
|
||||
{ label: $t('common.disabled'), value: 0 },
|
||||
],
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'status',
|
||||
label: $t('system.user.status'),
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
mode: 'multiple',
|
||||
allowClear: true,
|
||||
options: roleOptions,
|
||||
class: 'w-full',
|
||||
},
|
||||
fieldName: 'role_ids',
|
||||
formItemClass: 'items-start',
|
||||
label: $t('system.user.setPermissions'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'username',
|
||||
label: $t('system.user.userName'),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'real_name',
|
||||
label: $t('system.user.realName'),
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: $t('common.enabled'), value: 1 },
|
||||
{ label: $t('common.disabled'), value: 0 },
|
||||
],
|
||||
},
|
||||
fieldName: 'status',
|
||||
label: $t('system.user.status'),
|
||||
},
|
||||
{
|
||||
component: 'RangePicker',
|
||||
fieldName: 'create_time',
|
||||
label: $t('system.user.createTime'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useColumns<T = SystemUserApi.SystemUser>(
|
||||
onActionClick: OnActionClickFn<T>,
|
||||
onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'username',
|
||||
title: $t('system.user.userName'),
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'real_name',
|
||||
title: $t('system.user.realName'),
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
cellRender: {
|
||||
attrs: { beforeChange: onStatusChange },
|
||||
name: onStatusChange ? 'CellSwitch' : 'CellTag',
|
||||
},
|
||||
field: 'status',
|
||||
title: $t('system.user.status'),
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'create_time',
|
||||
title: $t('system.user.createTime'),
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'username',
|
||||
nameTitle: $t('system.user.userName'),
|
||||
onClick: onActionClick,
|
||||
},
|
||||
options: [
|
||||
{ code: 'edit', text: '编辑' },
|
||||
{ code: 'resetPassword', text: '重置密码' },
|
||||
{ code: 'delete', text: '删除' },
|
||||
],
|
||||
name: 'CellOperation',
|
||||
},
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
title: $t('system.user.operation'),
|
||||
width: 200,
|
||||
},
|
||||
];
|
||||
}
|
||||
179
apps/web-antd/src/views/system/user/list.vue
Normal file
179
apps/web-antd/src/views/system/user/list.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { SystemUserApi } from '#/api';
|
||||
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
import { Plus } from '@vben/icons';
|
||||
|
||||
import { Button, message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteUser, getUserList, updateUser } from '#/api';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useColumns, useGridFormSchema } from './data';
|
||||
import Form from './modules/form.vue';
|
||||
import ResetPasswordForm from './modules/reset-password-form.vue';
|
||||
|
||||
const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [ResetPasswordDrawer, resetPasswordDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: ResetPasswordForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
fieldMappingTime: [['create_time', ['startTime', 'endTime']]],
|
||||
schema: useGridFormSchema(),
|
||||
submitOnChange: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useColumns(onActionClick, onStatusChange),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getUserList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: { code: 'query' },
|
||||
search: true,
|
||||
zoom: true,
|
||||
},
|
||||
} as VxeTableGridOptions<SystemUserApi.SystemUser>,
|
||||
});
|
||||
|
||||
function onActionClick(e: OnActionClickParams<SystemUserApi.SystemUser>) {
|
||||
switch (e.code) {
|
||||
case 'delete': {
|
||||
onDelete(e.row);
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
onEdit(e.row);
|
||||
break;
|
||||
}
|
||||
case 'resetPassword': {
|
||||
onResetPassword(e.row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Antd的Modal.confirm封装为promise,方便在异步函数中调用。
|
||||
* @param content 提示内容
|
||||
* @param title 提示标题
|
||||
*/
|
||||
function confirm(content: string, title: string) {
|
||||
return new Promise((reslove, reject) => {
|
||||
Modal.confirm({
|
||||
content,
|
||||
onCancel() {
|
||||
reject(new Error('已取消'));
|
||||
},
|
||||
onOk() {
|
||||
reslove(true);
|
||||
},
|
||||
title,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态开关即将改变
|
||||
* @param newStatus 期望改变的状态值
|
||||
* @param row 行数据
|
||||
* @returns 返回false则中止改变,返回其他值(undefined、true)则允许改变
|
||||
*/
|
||||
async function onStatusChange(
|
||||
newStatus: number,
|
||||
row: SystemUserApi.SystemUser,
|
||||
) {
|
||||
const status: Recordable<string> = {
|
||||
0: '禁用',
|
||||
1: '启用',
|
||||
};
|
||||
try {
|
||||
await confirm(
|
||||
`你要将${row.username}的状态切换为 【${status[newStatus.toString()]}】 吗?`,
|
||||
`切换状态`,
|
||||
);
|
||||
await updateUser(row.id, { status: newStatus });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function onEdit(row: SystemUserApi.SystemUser) {
|
||||
formDrawerApi.setData(row).open();
|
||||
}
|
||||
|
||||
function onResetPassword(row: SystemUserApi.SystemUser) {
|
||||
resetPasswordDrawerApi.setData(row).open();
|
||||
}
|
||||
|
||||
function onDelete(row: SystemUserApi.SystemUser) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.username]),
|
||||
duration: 0,
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
deleteUser(row.id)
|
||||
.then(() => {
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.username]),
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
onRefresh();
|
||||
})
|
||||
.catch(() => {
|
||||
hideLoading();
|
||||
});
|
||||
}
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
function onCreate() {
|
||||
formDrawerApi.setData({}).open();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormDrawer />
|
||||
<ResetPasswordDrawer @success="onRefresh" />
|
||||
<Grid :table-title="$t('system.user.list')">
|
||||
<template #toolbar-tools>
|
||||
<Button type="primary" @click="onCreate">
|
||||
<Plus class="size-5" />
|
||||
{{ $t('ui.actionTitle.create', [$t('system.user.name')]) }}
|
||||
</Button>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
125
apps/web-antd/src/views/system/user/modules/form.vue
Normal file
125
apps/web-antd/src/views/system/user/modules/form.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type { SystemUserApi } from '#/api/system/user';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Spin } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { getRoleList } from '#/api/system/role';
|
||||
import { createUser, updateUser } from '#/api/system/user';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { roleOptions, useFormSchema } from '../data';
|
||||
|
||||
const emits = defineEmits(['success']);
|
||||
|
||||
const formData = ref<SystemUserApi.SystemUser>();
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const roles = ref<Recordable<any>[]>([]);
|
||||
const loadingRoles = ref(false);
|
||||
|
||||
const id = ref();
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
const values = await formApi.getValues();
|
||||
|
||||
// 确保role_ids是数字数组
|
||||
if (values.role_ids && Array.isArray(values.role_ids)) {
|
||||
values.role_ids = values.role_ids.map((id) =>
|
||||
typeof id === 'string' ? Number.parseInt(id, 10) : id,
|
||||
);
|
||||
}
|
||||
|
||||
drawerApi.lock();
|
||||
(id.value ? updateUser(id.value, values) : createUser(values))
|
||||
.then(() => {
|
||||
emits('success');
|
||||
drawerApi.close();
|
||||
})
|
||||
.catch(() => {
|
||||
drawerApi.unlock();
|
||||
});
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<SystemUserApi.SystemUser>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
id.value = data.id;
|
||||
|
||||
// 确保role_ids存在且为数组
|
||||
if (data.role_ids && !Array.isArray(data.role_ids)) {
|
||||
data.role_ids = [data.role_ids];
|
||||
}
|
||||
|
||||
formApi.setValues(data);
|
||||
} else {
|
||||
id.value = undefined;
|
||||
}
|
||||
|
||||
if (roleOptions.value.length === 0) {
|
||||
loadRoles();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function loadRoles() {
|
||||
loadingRoles.value = true;
|
||||
try {
|
||||
const res = await getRoleList({ page: 1, pageSize: 1000 });
|
||||
roles.value = res.items;
|
||||
|
||||
// 更新全局的roleOptions
|
||||
roleOptions.value = roles.value.map((role) => ({
|
||||
label: role.role_name,
|
||||
value: role.id,
|
||||
}));
|
||||
} finally {
|
||||
loadingRoles.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const getDrawerTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('common.edit', $t('system.user.name'))
|
||||
: $t('common.create', $t('system.user.name'));
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Drawer :title="getDrawerTitle">
|
||||
<Spin :spinning="loadingRoles">
|
||||
<Form />
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</template>
|
||||
<style lang="css" scoped>
|
||||
:deep(.ant-tree-title) {
|
||||
.tree-actions {
|
||||
display: none;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-tree-title:hover) {
|
||||
.tree-actions {
|
||||
display: flex;
|
||||
flex: auto;
|
||||
justify-content: flex-end;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SystemUserApi } from '#/api/system/user';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer, z } from '@vben/common-ui';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { resetPassword } from '#/api/system/user';
|
||||
|
||||
const emits = defineEmits(['success']);
|
||||
|
||||
const formData = ref<SystemUserApi.SystemUser>();
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: [
|
||||
{
|
||||
component: 'InputPassword',
|
||||
fieldName: 'password',
|
||||
label: '新密码',
|
||||
rules: z.string().min(1, { message: '请输入新密码' }),
|
||||
},
|
||||
{
|
||||
component: 'InputPassword',
|
||||
fieldName: 'confirmPassword',
|
||||
label: '确认密码',
|
||||
dependencies: {
|
||||
rules(values) {
|
||||
const { password } = values;
|
||||
return z
|
||||
.string()
|
||||
.min(1, { message: '请确认密码' })
|
||||
.refine((value) => value === password, {
|
||||
message: '两次输入的密码不一致',
|
||||
});
|
||||
},
|
||||
triggerFields: ['password'],
|
||||
},
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const id = ref();
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
const values = await formApi.getValues();
|
||||
|
||||
drawerApi.lock();
|
||||
resetPassword(id.value, { password: values.password })
|
||||
.then(() => {
|
||||
emits('success');
|
||||
drawerApi.close();
|
||||
})
|
||||
.catch(() => {
|
||||
drawerApi.unlock();
|
||||
});
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<SystemUserApi.SystemUser>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
id.value = data.id;
|
||||
} else {
|
||||
id.value = undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getDrawerTitle = computed(() => {
|
||||
return '重置密码';
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Drawer :title="getDrawerTitle">
|
||||
<Form />
|
||||
</Drawer>
|
||||
</template>
|
||||
Reference in New Issue
Block a user