f
Some checks failed
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
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 Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled

This commit is contained in:
2026-01-10 18:11:29 +08:00
commit 69f3c4ce45
1443 changed files with 125558 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
# \_core
此目录包含应用程序正常运行所需的基本视图。这些视图是应用程序布局中使用的视图。

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { About } from '@vben/common-ui';
defineOptions({ name: 'About' });
</script>
<template>
<About />
</template>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
</script>
<template>
<Fallback status="coming-soon" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'Fallback403Demo' });
</script>
<template>
<Fallback status="403" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'Fallback500Demo' });
</script>
<template>
<Fallback status="500" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'Fallback404Demo' });
</script>
<template>
<Fallback status="404" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'FallbackOfflineDemo' });
</script>
<template>
<Fallback status="offline" />
</template>

View File

@@ -0,0 +1,74 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
// 佣金记录列表列配置
export function useCommissionColumns(): 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,
},
];
}
// 佣金记录搜索表单配置
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 },
],
},
},
];
}

View 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 { getAgentCommissionList } from '#/api/agent';
import { useCommissionColumns, useCommissionFormSchema } 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: useCommissionFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useCommissionColumns(),
proxyConfig: {
ajax: {
query: async ({
page,
form,
}: {
form: Record<string, any>;
page: QueryParams;
}) => {
return await getAgentCommissionList({
...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>

View File

@@ -0,0 +1,274 @@
<script lang="ts" setup>
import type { AgentApi } from '#/api/agent';
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, Col, Form, InputNumber, Row, Space, message } from 'ant-design-vue';
import { getAgentConfig, updateAgentConfig } from '#/api/agent';
const loading = ref(false);
const config = ref<AgentApi.AgentConfig | null>(null);
// 使用 reactive 管理表单数据(价格配置已移除,改为产品配置表管理)
const formData = reactive<AgentApi.AgentConfig>({
level_bonus: {
normal: 0,
gold: 0,
diamond: 0,
},
upgrade_fee: {
normal_to_gold: 0,
normal_to_diamond: 0,
gold_to_diamond: 0,
},
upgrade_rebate: {
normal_to_gold_rebate: 0,
to_diamond_rebate: 0,
},
direct_parent_rebate: {
diamond: 0,
gold: 0,
normal: 0,
},
max_gold_rebate_amount: 0,
commission_freeze: {
ratio: 0,
threshold: 0,
days: 0,
},
tax_rate: 0,
tax_exemption_amount: 0,
gold_max_uplift_amount: 0,
diamond_max_uplift_amount: 0,
});
// 加载配置
async function loadConfig() {
loading.value = true;
try {
const res = await getAgentConfig();
config.value = res;
Object.assign(formData, res);
} catch (error) {
console.error('加载配置失败:', error);
} finally {
loading.value = false;
}
}
// 保存配置
async function handleSave() {
try {
const params: AgentApi.UpdateAgentConfigParams = {
level_bonus: {
normal: formData.level_bonus.normal,
gold: formData.level_bonus.gold,
diamond: formData.level_bonus.diamond,
},
upgrade_fee: {
normal_to_gold: formData.upgrade_fee.normal_to_gold,
normal_to_diamond: formData.upgrade_fee.normal_to_diamond,
},
upgrade_rebate: {
normal_to_gold_rebate: formData.upgrade_rebate.normal_to_gold_rebate,
to_diamond_rebate: formData.upgrade_rebate.to_diamond_rebate,
},
direct_parent_rebate: {
diamond: formData.direct_parent_rebate.diamond,
gold: formData.direct_parent_rebate.gold,
normal: formData.direct_parent_rebate.normal,
},
max_gold_rebate_amount: formData.max_gold_rebate_amount,
commission_freeze: {
ratio: formData.commission_freeze.ratio,
threshold: formData.commission_freeze.threshold,
days: formData.commission_freeze.days,
},
tax_rate: formData.tax_rate,
tax_exemption_amount: formData.tax_exemption_amount,
gold_max_uplift_amount: formData.gold_max_uplift_amount,
diamond_max_uplift_amount: formData.diamond_max_uplift_amount,
};
await updateAgentConfig(params);
message.success('配置保存成功');
loadConfig();
} catch (error) {
console.error('保存配置失败:', error);
}
}
// 重置配置
function handleReset() {
if (config.value) {
Object.assign(formData, config.value);
}
}
onMounted(() => {
loadConfig();
});
</script>
<template>
<Page auto-content-height>
<Card title="系统配置" :loading="loading">
<Form layout="vertical">
<Card title="等级奖金" size="small" class="mb-4">
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['level_bonus', 'normal']" label="普通代理奖金">
<InputNumber v-model:value="formData.level_bonus.normal" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['level_bonus', 'gold']" label="黄金代理奖金">
<InputNumber v-model:value="formData.level_bonus.gold" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['level_bonus', 'diamond']" label="钻石代理奖金">
<InputNumber v-model:value="formData.level_bonus.diamond" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
</Row>
</Card>
<Card title="等级最高价上调金额" size="small" class="mb-4">
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<Form.Item name="gold_max_uplift_amount" label="黄金代理最高价上调金额">
<InputNumber v-model:value="formData.gold_max_uplift_amount" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<Form.Item name="diamond_max_uplift_amount" label="钻石代理最高价上调金额">
<InputNumber v-model:value="formData.diamond_max_uplift_amount" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
</Row>
</Card>
<Card title="升级费用" size="small" class="mb-4">
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['upgrade_fee', 'normal_to_gold']" label="普通→黄金">
<InputNumber v-model:value="formData.upgrade_fee.normal_to_gold" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['upgrade_fee', 'normal_to_diamond']" label="普通→钻石">
<InputNumber v-model:value="formData.upgrade_fee.normal_to_diamond" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
</Row>
</Card>
<Card title="升级返佣" size="small" class="mb-4">
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<Form.Item :name="['upgrade_rebate', 'normal_to_gold_rebate']" label="普通→黄金返佣">
<InputNumber v-model:value="formData.upgrade_rebate.normal_to_gold_rebate" :min="0" :precision="2"
:step="0.01" style="width: 100%" addon-after="" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<Form.Item :name="['upgrade_rebate', 'to_diamond_rebate']" label="→钻石返佣">
<InputNumber v-model:value="formData.upgrade_rebate.to_diamond_rebate" :min="0" :precision="2"
:step="0.01" style="width: 100%" addon-after="" />
</Form.Item>
</Col>
</Row>
</Card>
<Card title="直接上级返佣配置" size="small" class="mb-4">
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['direct_parent_rebate', 'diamond']" label="直接上级是钻石的返佣金额">
<InputNumber v-model:value="formData.direct_parent_rebate.diamond" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['direct_parent_rebate', 'gold']" label="直接上级是黄金的返佣金额">
<InputNumber v-model:value="formData.direct_parent_rebate.gold" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['direct_parent_rebate', 'normal']" label="直接上级是普通的返佣金额">
<InputNumber v-model:value="formData.direct_parent_rebate.normal" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
</Row>
</Card>
<Card title="返佣限额配置" size="small" class="mb-4">
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<Form.Item name="max_gold_rebate_amount" label="黄金代理最大返佣金额">
<InputNumber v-model:value="formData.max_gold_rebate_amount" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
</Row>
</Card>
<Card title="佣金冻结配置" size="small" class="mb-4">
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['commission_freeze', 'ratio']" label="佣金冻结比例例如0.1表示10%">
<InputNumber v-model:value="formData.commission_freeze.ratio" :min="0" :max="1" :precision="4"
:step="0.0001" style="width: 100%" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['commission_freeze', 'threshold']" label="佣金冻结阈值">
<InputNumber v-model:value="formData.commission_freeze.threshold" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<Form.Item :name="['commission_freeze', 'days']" label="佣金冻结解冻天数">
<InputNumber v-model:value="formData.commission_freeze.days" :min="0" :precision="0" :step="1"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
</Row>
</Card>
<Card title="税费配置" size="small" class="mb-4">
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<Form.Item name="tax_rate" label="税率例如0.06表示6%">
<InputNumber v-model:value="formData.tax_rate" :min="0" :max="1" :precision="4" :step="0.0001"
style="width: 100%" />
</Form.Item>
</Col>
<Col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<Form.Item name="tax_exemption_amount" label="免税额度">
<InputNumber v-model:value="formData.tax_exemption_amount" :min="0" :precision="2" :step="0.01"
style="width: 100%" addon-after="" />
</Form.Item>
</Col>
</Row>
</Card>
<Space>
<Button type="primary" @click="handleSave">保存</Button>
<Button @click="handleReset">重置</Button>
</Space>
</Form>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,109 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { getLevelName } from '#/utils/agent';
export function useInviteCodeColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
},
{
field: 'code',
title: '邀请码',
width: 200,
},
{
field: 'agent_id',
title: '发放代理ID',
width: 120,
},
{
field: 'agent_mobile',
title: '发放代理手机号',
width: 140,
},
{
field: 'target_level',
title: '目标等级',
width: 100,
formatter: ({ cellValue }: { cellValue: number }) => {
return getLevelName(cellValue);
},
},
{
field: 'status',
title: '状态',
width: 100,
cellRender: {
name: 'CellTag',
options: [
{ value: 0, color: 'default', label: '未使用' },
{ value: 1, color: 'success', label: '已使用' },
{ value: 2, color: 'error', label: '已失效' },
],
},
},
{
field: 'used_user_id',
title: '使用用户ID',
width: 120,
},
{
field: 'used_agent_id',
title: '使用代理ID',
width: 120,
},
{
field: 'used_time',
title: '使用时间',
width: 160,
},
{
field: 'expire_time',
title: '过期时间',
width: 160,
},
{
field: 'remark',
title: '备注',
width: 200,
},
{
field: 'create_time',
title: '创建时间',
width: 160,
sortable: true,
},
] as const;
}
export function useInviteCodeFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'code',
label: '邀请码',
},
{
component: 'InputNumber',
fieldName: 'agent_id',
label: '发放代理ID',
},
{
component: 'Select',
fieldName: 'status',
label: '状态',
componentProps: {
allowClear: true,
options: [
{ label: '未使用', value: 0 },
{ label: '已使用', value: 1 },
{ label: '已失效', value: 2 },
],
},
},
];
}

View File

@@ -0,0 +1,175 @@
<script lang="ts" setup>
import type { AgentApi } from '#/api/agent';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Input, InputNumber, Modal, Space, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
generateDiamondInviteCode,
getInviteCodeList,
} from '#/api/agent';
import { useInviteCodeColumns, useInviteCodeFormSchema } from './data';
interface QueryParams {
currentPage: number;
pageSize: number;
[key: string]: any;
}
const generateModalVisible = ref(false);
const generatedCodes = ref<string[]>([]);
const generateForm = ref({
count: 1,
expire_days: 0,
remark: '',
});
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useInviteCodeFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useInviteCodeColumns(),
proxyConfig: {
ajax: {
query: async ({
page,
form,
}: {
form: Record<string, any>;
page: QueryParams;
}) => {
return await getInviteCodeList({
...form,
target_level: 3,
page: page.currentPage,
pageSize: page.pageSize,
});
},
},
props: {
result: 'items',
total: 'total',
},
},
},
});
// 生成邀请码
function handleGenerate() {
generateForm.value = {
count: 1,
expire_days: 0,
remark: '',
};
generatedCodes.value = [];
generateModalVisible.value = true;
}
// 确认生成
async function confirmGenerate() {
try {
const res = await generateDiamondInviteCode({
count: generateForm.value.count,
expire_days:
generateForm.value.expire_days > 0
? generateForm.value.expire_days
: undefined,
remark: generateForm.value.remark || undefined,
});
generatedCodes.value = res.codes;
message.success('邀请码生成成功');
gridApi.query();
} catch (error) {
console.error('生成邀请码失败:', error);
}
}
// 复制邀请码
function copyCodes() {
const text = generatedCodes.value.join('\n');
navigator.clipboard.writeText(text).then(() => {
message.success('邀请码已复制到剪贴板');
});
}
// 关闭弹窗
function closeGenerateModal() {
generateModalVisible.value = false;
generatedCodes.value = [];
}
</script>
<template>
<Page auto-content-height>
<Grid table-title="邀请码列表">
<template #toolbar>
<Button type="primary" @click="handleGenerate">生成钻石邀请码</Button>
</template>
</Grid>
<Modal v-model:open="generateModalVisible" :title="generatedCodes.length > 0 ? '生成的邀请码' : '生成钻石邀请码'" :width="600"
@cancel="closeGenerateModal">
<div v-if="generatedCodes.length === 0">
<div class="mb-4">
<label>生成数量</label>
<InputNumber v-model:value="generateForm.count" :min="1" :max="100" class="w-full" />
</div>
<div class="mb-4">
<label>过期天数0表示不过期</label>
<InputNumber v-model:value="generateForm.expire_days" :min="0" class="w-full" />
</div>
<div class="mb-4">
<label>备注</label>
<Input.TextArea v-model:value="generateForm.remark" :rows="3" placeholder="请输入备注" />
</div>
</div>
<div v-else>
<div class="mb-4">
<strong>已生成 {{ generatedCodes.length }} 个邀请码</strong>
</div>
<div class="code-list">
<div v-for="(code, index) in generatedCodes" :key="index" class="code-item">
{{ code }}
</div>
</div>
</div>
<template #footer>
<Button v-if="generatedCodes.length > 0" @click="closeGenerateModal">
关闭
</Button>
<Button v-else @click="closeGenerateModal">取消</Button>
<Button v-if="generatedCodes.length > 0" type="primary" @click="copyCodes">
复制所有
</Button>
<Button v-else type="primary" @click="confirmGenerate">生成</Button>
</template>
</Modal>
</Page>
</template>
<style lang="less" scoped>
.code-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 8px;
}
.code-item {
padding: 4px 8px;
font-family: monospace;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
</style>

View File

@@ -0,0 +1,67 @@
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_id',
title: '产品ID',
width: 100,
},
{
field: 'product_name',
title: '产品名称',
width: 150,
},
{
field: 'set_price',
title: '设定价格',
width: 120,
formatter: ({ cellValue }) => `¥${cellValue.toFixed(2)}`,
},
{
field: 'actual_base_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: 'InputNumber',
fieldName: 'product_id',
label: '产品ID',
},
{
component: 'Input',
fieldName: 'product_name',
label: '产品名称',
},
{
component: 'Input',
fieldName: 'link_identifier',
label: '推广码',
},
];
}

View 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>

View File

@@ -0,0 +1,446 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { getLevelName } from '#/utils/agent';
// 表单配置
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'mobile',
label: '手机号',
rules: 'required',
},
{
component: 'Select',
fieldName: 'level',
label: '等级',
rules: 'required',
componentProps: {
disabled: true,
options: [
{ label: '普通代理', value: 1 },
{ label: '黄金代理', value: 2 },
{ label: '钻石代理', value: 3 },
],
},
},
{
component: 'Input',
fieldName: 'region',
label: '区域',
rules: 'required',
},
{
component: 'Input',
fieldName: 'wechat_id',
label: '微信号',
},
];
}
// 搜索表单配置
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'mobile',
label: '手机号',
},
{
component: 'Input',
fieldName: 'region',
label: '区域',
},
{
component: 'Select',
fieldName: 'level',
label: '等级',
componentProps: {
allowClear: true,
options: [
{ label: '普通代理', value: 1 },
{ label: '黄金代理', value: 2 },
{ label: '钻石代理', value: 3 },
],
},
},
{
component: 'InputNumber',
fieldName: 'team_leader_id',
label: '团队首领ID',
},
{
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: 'agent_code',
title: '代理编码',
width: 100,
},
{
field: 'level',
title: '等级',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) => {
return getLevelName(cellValue);
},
},
{
field: 'region',
title: '区域',
width: 120,
},
{
field: 'mobile',
title: '手机号',
width: 120,
},
{
cellRender: {
name: 'CellTag',
options: [
{ value: true, color: 'success', label: '已认证' },
{ value: false, color: 'default', label: '未认证' },
],
},
field: 'is_real_name',
title: '实名认证状态',
width: 120,
},
{
field: 'wechat_id',
title: '微信号',
width: 120,
visible: false,
},
{
field: 'team_leader_id',
title: '团队首领ID',
width: 120,
visible: false,
},
{
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 },
],
},
},
];
}

View File

@@ -0,0 +1,329 @@
<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 CommissionModal from './modules/commission-modal.vue';
import Form from './modules/form.vue';
import LinkModal from './modules/link-modal.vue';
import OrderModal from './modules/order-modal.vue';
import RebateModal from './modules/rebate-modal.vue';
import UpgradeModal from './modules/upgrade-modal.vue';
import WithdrawalModal from './modules/withdrawal-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 [RebateModalComponent, rebateModalApi] = useVbenModal({
connectedComponent: RebateModal,
destroyOnClose: true,
});
// 升级记录弹窗
const [UpgradeModalComponent, upgradeModalApi] = useVbenModal({
connectedComponent: UpgradeModal,
destroyOnClose: true,
});
// 订单记录弹窗
const [OrderModalComponent, orderModalApi] = useVbenModal({
connectedComponent: OrderModal,
destroyOnClose: true,
});
// 提现记录弹窗
const [WithdrawalModalComponent, withdrawalModalApi] = useVbenModal({
connectedComponent: WithdrawalModal,
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,
team_leader_id: route.query.team_leader_id
? Number(route.query.team_leader_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: 'links',
label: '推广链接',
},
{
key: 'rebate',
label: '返佣记录',
},
{
key: 'upgrade',
label: '升级记录',
},
{
key: 'order',
label: '订单记录',
},
{
key: 'withdrawal',
label: '提现记录',
},
];
// 团队首领信息
const teamLeaderId = computed(() => route.query.team_leader_id);
// 返回团队首领列表
function onBackToParent() {
router.replace({
query: {
...route.query,
team_leader_id: undefined,
},
});
}
// 操作处理函数
function onActionClick(
e:
| OnActionClickParams<AgentApi.AgentListItem>
| { code: string; row: AgentApi.AgentListItem },
) {
switch (e.code) {
case 'commission': {
onViewCommission(e.row);
break;
}
case 'edit': {
onEdit(e.row);
break;
}
case 'links': {
onViewLinks(e.row);
break;
}
case 'rebate': {
onViewRebate(e.row);
break;
}
case 'upgrade': {
onViewUpgrade(e.row);
break;
}
case 'order': {
onViewOrder(e.row);
break;
}
case 'view-sub-agent': {
router.replace({
query: {
...route.query,
team_leader_id: e.row.id,
},
});
break;
}
case 'withdrawal': {
onViewWithdrawal(e.row);
break;
}
}
}
// 编辑处理
function onEdit(row: AgentApi.AgentListItem) {
formDrawerApi.setData(row).open();
}
// 查看推广链接
function onViewLinks(row: AgentApi.AgentListItem) {
linkModalApi.setData({ agentId: row.id }).open();
}
// 查看佣金记录
function onViewCommission(row: AgentApi.AgentListItem) {
commissionModalApi.setData({ agentId: row.id }).open();
}
// 查看返佣记录
function onViewRebate(row: AgentApi.AgentListItem) {
rebateModalApi.setData({ agentId: row.id }).open();
}
// 查看升级记录
function onViewUpgrade(row: AgentApi.AgentListItem) {
upgradeModalApi.setData({ agentId: row.id }).open();
}
// 查看订单记录
function onViewOrder(row: AgentApi.AgentListItem) {
orderModalApi.setData({ agentId: row.id }).open();
}
// 查看提现记录
function onViewWithdrawal(row: AgentApi.AgentListItem) {
withdrawalModalApi.setData({ agentId: row.id }).open();
}
// 刷新处理
function onRefresh() {
gridApi.query();
}
</script>
<template>
<Page auto-content-height>
<FormDrawer @success="onRefresh" />
<LinkModalComponent />
<CommissionModalComponent />
<RebateModalComponent />
<UpgradeModalComponent />
<OrderModalComponent />
<WithdrawalModalComponent />
<!-- 团队首领信息卡片 -->
<Card v-if="teamLeaderId" class="mb-4">
<div class="flex items-center gap-4">
<Button @click="onBackToParent">返回上级列表</Button>
<div>团队首领ID{{ teamLeaderId }}</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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -0,0 +1,33 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import OrderList from '../../agent-order/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-order-modal">
<OrderList :agent-id="modalData?.agentId" />
</div>
</Modal>
</template>
<style lang="less" scoped>
.agent-order-modal {
padding: 16px;
}
</style>

View File

@@ -0,0 +1,33 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import RebateList from '../../agent-rebate/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-rebate-modal">
<RebateList :agent-id="modalData?.agentId" />
</div>
</Modal>
</template>
<style lang="less" scoped>
.agent-rebate-modal {
padding: 16px;
}
</style>

View File

@@ -0,0 +1,33 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import UpgradeList from '../../agent-upgrade/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-upgrade-modal">
<UpgradeList :agent-id="modalData?.agentId" />
</div>
</Modal>
</template>
<style lang="less" scoped>
.agent-upgrade-modal {
padding: 16px;
}
</style>

View File

@@ -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>

View File

@@ -0,0 +1,117 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { getOrderProcessStatusName } from '#/utils/agent';
export function useOrderColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
},
{
field: 'agent_id',
title: '代理ID',
width: 100,
},
{
field: 'order_id',
title: '订单ID',
width: 100,
},
{
field: 'product_id',
title: '产品ID',
width: 100,
},
{
field: 'product_name',
title: '产品名称',
width: 150,
},
{
field: 'order_amount',
title: '订单金额',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
},
{
field: 'set_price',
title: '设定价格',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
},
{
field: 'actual_base_price',
title: '实际底价',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
},
{
field: 'price_cost',
title: '提价成本',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
},
{
field: 'agent_profit',
title: '代理收益',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
},
{
field: 'process_status',
title: '处理状态',
width: 100,
cellRender: {
name: 'CellTag',
options: [
{ value: 0, color: 'warning', label: '待处理' },
{ value: 1, color: 'success', label: '处理成功' },
{ value: 2, color: 'error', label: '处理失败' },
],
},
},
{
field: 'create_time',
title: '创建时间',
width: 160,
sortable: true,
},
] as const;
}
export function useOrderFormSchema(): VbenFormSchema[] {
return [
{
component: 'InputNumber',
fieldName: 'agent_id',
label: '代理ID',
},
{
component: 'InputNumber',
fieldName: 'order_id',
label: '订单ID',
},
{
component: 'Select',
fieldName: 'process_status',
label: '处理状态',
componentProps: {
allowClear: true,
options: [
{ label: '待处理', value: 0 },
{ label: '处理成功', value: 1 },
{ label: '处理失败', value: 2 },
],
},
},
];
}

View 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 { getAgentOrderList } from '#/api/agent';
import { useOrderColumns, useOrderFormSchema } 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: useOrderFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useOrderColumns(),
proxyConfig: {
ajax: {
query: async ({
page,
form,
}: {
form: Record<string, any>;
page: QueryParams;
}) => {
return await getAgentOrderList({
...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>

View File

@@ -0,0 +1,193 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { h } from 'vue';
import { z } from '#/adapter/form';
// 代理产品配置列表列配置
export function useColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
},
{
field: 'product_name',
title: '产品名称',
},
{
field: 'base_price',
title: '基础底价',
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
},
{
field: 'price_range_max',
title: '最高定价',
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
},
{
field: 'price_threshold',
title: '价格阈值',
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
},
{
field: 'price_fee_rate',
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: 'base_price',
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: 'price_threshold',
label: '价格阈值',
defaultValue: undefined,
componentProps: {
min: 0,
precision: 2,
step: 0.01,
placeholder: '可选,不设置则不限制',
},
rules: z
.number({
invalid_type_error: '请输入有效的数字',
})
.min(0, '价格阈值不能小于0')
.optional()
.nullable(),
dependencies: {
triggerFields: ['base_price'],
rules(values) {
// 动态校验:价格阈值不能低于基础底价
const basePrice = values.base_price;
if (basePrice !== undefined && basePrice !== null) {
return z
.number({
invalid_type_error: '请输入有效的数字',
})
.min(0, '价格阈值不能小于0')
.refine(
(val) => {
if (val === undefined || val === null) return true;
return val >= basePrice;
},
{
message: `价格阈值不能低于基础底价 ${basePrice}`,
},
)
.optional()
.nullable();
}
return z
.number({
invalid_type_error: '请输入有效的数字',
})
.min(0, '价格阈值不能小于0')
.optional()
.nullable();
},
trigger(_values, formApi) {
// 当基础底价变化时,重新校验价格阈值
formApi.validateField('price_threshold');
},
},
suffix: () => h('span', { class: 'text-gray-400 text-xs' }, '可选'),
},
{
component: 'InputNumber',
fieldName: 'price_fee_rate',
label: '提价费率',
defaultValue: undefined,
componentProps: {
min: 0,
max: 100,
precision: 4,
step: 0.01,
addonAfter: '%',
controls: true,
placeholder: '可选,不设置则不收费',
validateTrigger: ['blur', 'change'],
},
rules: z
.number({
invalid_type_error: '请输入有效的数字',
})
.min(0, '提价费率不能小于0')
.max(100, '提价费率不能大于100%')
.optional()
.nullable(),
dependencies: {
triggerFields: ['price_threshold'],
required(values) {
// 如果价格阈值有值,提价费率必填
return values.price_threshold !== undefined && values.price_threshold !== null;
},
rules(values) {
// 如果价格阈值有值,提价费率必填
if (values.price_threshold !== undefined && values.price_threshold !== null) {
return z.number().min(0, '提价费率不能小于0').max(100, '提价费率不能大于100%');
}
return z.number().optional().nullable();
},
trigger(values, formApi) {
// 当价格阈值清空时,也清空提价费率
if (values.price_threshold === undefined || values.price_threshold === null) {
formApi.setFieldValue('price_fee_rate', undefined);
}
},
},
suffix: () => h('span', { class: 'text-gray-400 text-xs' }, '可选'),
},
];
}

View 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>

View File

@@ -0,0 +1,95 @@
<script lang="ts" setup>
import type { AgentApi } from '#/api/agent';
import { ref } from 'vue';
import { Button, InputNumber } from 'ant-design-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,
base_price: values.base_price,
price_range_max: values.price_range_max,
price_threshold: values.price_threshold ?? undefined,
price_fee_rate: values.price_fee_rate ? values.price_fee_rate / 100 : undefined,
};
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,
price_threshold: data.price_threshold ?? undefined,
price_fee_rate: data.price_fee_rate ? data.price_fee_rate * 100 : undefined,
});
} else {
id.value = undefined;
}
}
},
});
</script>
<template>
<Drawer :title="drawerTitle">
<Form>
<template #price_threshold="slotProps">
<div class="flex items-center gap-2">
<InputNumber :value="slotProps.componentField.modelValue" :min="0" :precision="2" :step="0.01"
placeholder="可选,不设置则不限制" class="flex-1" @update:value="slotProps.componentField['onUpdate:modelValue']" />
<Button type="link" size="small" @click="() => {
formApi.setFieldValue('price_threshold', undefined);
formApi.setFieldValue('price_fee_rate', undefined);
}">
不设置
</Button>
</div>
</template>
<template #price_fee_rate="slotProps">
<div class="flex items-center gap-2">
<InputNumber :value="slotProps.componentField.modelValue" :min="0" :max="100" :precision="4" :step="0.01"
addon-after="%" placeholder="可选,不设置则不收费" class="flex-1"
@update:value="slotProps.componentField['onUpdate:modelValue']" />
<Button type="link" size="small" @click="() => {
formApi.setFieldValue('price_fee_rate', undefined);
}">
不设置
</Button>
</div>
</template>
</Form>
</Drawer>
</template>

View File

@@ -0,0 +1,86 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { maskIdCard, maskMobile, getRealNameStatusName } from '#/utils/agent';
export function useRealNameColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
},
{
field: 'agent_id',
title: '代理ID',
width: 100,
},
{
field: 'name',
title: '姓名',
width: 120,
},
{
field: 'id_card',
title: '身份证号',
width: 180,
formatter: ({ cellValue }: { cellValue: string }) => {
return maskIdCard(cellValue);
},
},
{
field: 'mobile',
title: '手机号',
width: 140,
formatter: ({ cellValue }: { cellValue: string }) => {
return maskMobile(cellValue);
},
},
{
field: 'status',
title: '状态',
width: 100,
cellRender: {
name: 'CellTag',
options: [
{ value: 1, color: 'warning', label: '未验证' },
{ value: 2, color: 'success', label: '已通过' },
],
},
},
{
field: 'verify_time',
title: '验证时间',
width: 160,
},
{
field: 'create_time',
title: '创建时间',
width: 160,
sortable: true,
},
] as const;
}
export function useRealNameFormSchema(): VbenFormSchema[] {
return [
{
component: 'InputNumber',
fieldName: 'agent_id',
label: '代理ID',
},
{
component: 'Select',
fieldName: 'status',
label: '状态',
componentProps: {
allowClear: true,
options: [
{ label: '未验证', value: 1 },
{ label: '已通过', value: 2 },
],
},
},
];
}

View File

@@ -0,0 +1,65 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAgentRealNameList } from '#/api/agent';
import { useRealNameColumns, useRealNameFormSchema } 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: useRealNameFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useRealNameColumns(),
proxyConfig: {
ajax: {
query: async ({
page,
form,
}: {
form: Record<string, any>;
page: QueryParams;
}) => {
return await getAgentRealNameList({
...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>

View File

@@ -0,0 +1,79 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { getRebateTypeName } from '#/utils/agent';
export function useRebateColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
},
{
field: 'agent_id',
title: '代理ID',
width: 100,
},
{
field: 'source_agent_id',
title: '来源代理ID',
width: 120,
},
{
field: 'order_id',
title: '订单ID',
width: 100,
},
{
field: 'rebate_type',
title: '返佣类型',
width: 140,
formatter: ({ cellValue }: { cellValue: number }) => {
return getRebateTypeName(cellValue);
},
},
{
field: 'amount',
title: '返佣金额',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
},
{
field: 'create_time',
title: '创建时间',
width: 160,
sortable: true,
},
] as const;
}
export function useRebateFormSchema(): VbenFormSchema[] {
return [
{
component: 'InputNumber',
fieldName: 'agent_id',
label: '代理ID',
},
{
component: 'InputNumber',
fieldName: 'source_agent_id',
label: '来源代理ID',
},
{
component: 'Select',
fieldName: 'rebate_type',
label: '返佣类型',
componentProps: {
allowClear: true,
options: [
{ label: '直接上级返佣', value: 1 },
{ label: '钻石上级返佣', value: 2 },
{ label: '黄金上级返佣', value: 3 },
],
},
},
];
}

View File

@@ -0,0 +1,65 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAgentRebateList } from '#/api/agent';
import { useRebateColumns, useRebateFormSchema } 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: useRebateFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useRebateColumns(),
proxyConfig: {
ajax: {
query: async ({
page,
form,
}: {
form: Record<string, any>;
page: QueryParams;
}) => {
return await getAgentRebateList({
...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>

View 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,
},
},
];
}

View 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>

View File

@@ -0,0 +1,116 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import {
getLevelName,
getUpgradeStatusName,
getUpgradeTypeName,
} from '#/utils/agent';
export function useUpgradeColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
},
{
field: 'agent_id',
title: '代理ID',
width: 100,
},
{
field: 'from_level',
title: '原等级',
width: 100,
formatter: ({ cellValue }: { cellValue: number }) => {
return getLevelName(cellValue);
},
},
{
field: 'to_level',
title: '目标等级',
width: 100,
formatter: ({ cellValue }: { cellValue: number }) => {
return getLevelName(cellValue);
},
},
{
field: 'upgrade_type',
title: '升级类型',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) => {
return getUpgradeTypeName(cellValue);
},
},
{
field: 'upgrade_fee',
title: '升级费用',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
},
{
field: 'rebate_amount',
title: '返佣金额',
width: 120,
formatter: ({ cellValue }: { cellValue: number }) =>
`¥${cellValue.toFixed(2)}`,
},
{
field: 'status',
title: '状态',
width: 100,
cellRender: {
name: 'CellTag',
options: [
{ value: 1, color: 'warning', label: '待处理' },
{ value: 2, color: 'success', label: '已完成' },
{ value: 3, color: 'error', label: '已失败' },
],
},
},
{
field: 'create_time',
title: '创建时间',
width: 160,
sortable: true,
},
] as const;
}
export function useUpgradeFormSchema(): VbenFormSchema[] {
return [
{
component: 'InputNumber',
fieldName: 'agent_id',
label: '代理ID',
},
{
component: 'Select',
fieldName: 'upgrade_type',
label: '升级类型',
componentProps: {
allowClear: true,
options: [
{ label: '自主付费', value: 1 },
{ label: '钻石升级下级', value: 2 },
],
},
},
{
component: 'Select',
fieldName: 'status',
label: '状态',
componentProps: {
allowClear: true,
options: [
{ label: '待处理', value: 1 },
{ label: '已完成', value: 2 },
{ label: '已失败', value: 3 },
],
},
},
];
}

View File

@@ -0,0 +1,65 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAgentUpgradeList } from '#/api/agent';
import { useUpgradeColumns, useUpgradeFormSchema } 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: useUpgradeFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useUpgradeColumns(),
proxyConfig: {
ajax: {
query: async ({
page,
form,
}: {
form: Record<string, any>;
page: QueryParams;
}) => {
return await getAgentUpgradeList({
...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>

View File

@@ -0,0 +1,113 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
export function useWithdrawalColumns(): VxeTableGridOptions['columns'] {
return [
{
title: 'ID',
field: 'id',
width: 80,
},
{
title: '代理ID',
field: 'agent_id',
width: 100,
},
{
title: '提现单号',
field: 'withdraw_no',
width: 180,
},
{
title: '提现金额',
field: 'amount',
width: 120,
formatter: ({ cellValue }) => `¥${cellValue.toFixed(2)}`,
},
{
title: '税费金额',
field: 'tax_amount',
width: 120,
formatter: ({ cellValue }) => `¥${cellValue.toFixed(2)}`,
},
{
title: '实际到账金额',
field: 'actual_amount',
width: 120,
formatter: ({ cellValue }) => `¥${cellValue.toFixed(2)}`,
},
{
title: '收款账号',
field: 'payee_account',
width: 180,
},
{
title: '收款人姓名',
field: 'payee_name',
width: 120,
},
{
title: '状态',
field: 'status',
width: 100,
cellRender: {
name: 'CellTag',
options: [
{ value: 1, color: 'warning', label: '待审核' },
{ value: 2, color: 'success', label: '审核通过' },
{ value: 3, color: 'error', label: '审核拒绝' },
{ value: 4, color: 'processing', label: '提现中' },
{ value: 5, color: 'success', label: '提现成功' },
{ value: 6, color: 'error', label: '提现失败' },
],
},
},
{
title: '备注',
field: 'remark',
width: 200,
},
{
title: '创建时间',
field: 'create_time',
width: 160,
sortable: true,
},
{
align: 'center',
slots: { default: 'operation' },
field: 'operation',
fixed: 'right',
title: '操作',
width: 120,
},
];
}
export function useWithdrawalFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'withdraw_no',
label: '提现单号',
},
{
component: 'Select',
fieldName: 'status',
label: '状态',
componentProps: {
allowClear: true,
options: [
{ label: '待审核', value: 1 },
{ label: '审核通过', value: 2 },
{ label: '审核拒绝', value: 3 },
{ label: '提现中', value: 4 },
{ label: '提现成功', value: 5 },
{ label: '提现失败', value: 6 },
],
},
},
];
}

View File

@@ -0,0 +1,152 @@
<script lang="ts" setup>
import type { AgentApi } from '#/api/agent';
import { computed, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Input, Modal, Space, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { auditWithdrawal, getAgentWithdrawalList } from '#/api/agent';
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 auditRemark = ref('');
const auditModalVisible = ref(false);
const currentWithdrawal = ref<AgentApi.AgentWithdrawalListItem | null>(null);
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useWithdrawalFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useWithdrawalColumns(),
proxyConfig: {
ajax: {
query: async ({
page,
form,
}: {
page: QueryParams;
form: Record<string, any>;
}) => {
return await getAgentWithdrawalList({
...queryParams.value,
...form,
page: page.currentPage,
pageSize: page.pageSize,
});
},
},
props: {
result: 'items',
total: 'total',
},
},
},
});
// 审核提现
function handleAudit(row: AgentApi.AgentWithdrawalListItem) {
currentWithdrawal.value = row;
auditRemark.value = '';
auditModalVisible.value = true;
}
// 确认审核
async function confirmAudit(status: number) {
if (!currentWithdrawal.value) return;
try {
await auditWithdrawal({
withdrawal_id: currentWithdrawal.value.id,
status,
remark: auditRemark.value || '',
});
message.success('审核成功');
auditModalVisible.value = false;
gridApi.query();
} catch (error) {
console.error('审核失败:', error);
}
}
// 取消审核
function cancelAudit() {
auditModalVisible.value = false;
auditRemark.value = '';
currentWithdrawal.value = null;
}
</script>
<template>
<Page :auto-content-height="!agentId">
<Grid :table-title="agentId ? '提现记录列表' : '所有提现记录'">
<template #operation="{ row }">
<Space>
<Button v-if="row.status === 1" type="link" size="small" @click="handleAudit(row)">
审核
</Button>
</Space>
</template>
</Grid>
<Modal v-model:open="auditModalVisible" title="审核提现" :width="600" @cancel="cancelAudit">
<div v-if="currentWithdrawal" class="audit-info">
<p><strong>提现单号</strong>{{ currentWithdrawal.withdraw_no }}</p>
<p>
<strong>提现金额</strong>¥{{ currentWithdrawal.amount.toFixed(2) }}
</p>
<p><strong>收款账号</strong>{{ currentWithdrawal.payee_account }}</p>
<p><strong>收款人姓名</strong>{{ currentWithdrawal.payee_name }}</p>
</div>
<div class="audit-remark">
<p><strong>审核备注</strong></p>
<Input.TextArea v-model:value="auditRemark" :rows="4" placeholder="请输入审核备注" />
</div>
<template #footer>
<Button @click="cancelAudit">取消</Button>
<Button type="primary" danger @click="confirmAudit(3)">
拒绝
</Button>
<Button type="primary" @click="confirmAudit(2)">通过</Button>
</template>
</Modal>
</Page>
</template>
<style lang="less" scoped>
.audit-info {
margin-bottom: 16px;
p {
margin-bottom: 8px;
}
}
.audit-remark {
margin-top: 16px;
p {
margin-bottom: 8px;
}
}
</style>

View File

@@ -0,0 +1,81 @@
<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({
grid: {
bottom: 0,
containLabel: true,
left: '1%',
right: '1%',
top: '2%',
},
series: [
{
areaStyle: {},
data: [
120, 300, 500, 800, 1200, 1800, 2500, 3000, 2800, 2600, 2400, 2200,
2000, 1800, 1600, 1400, 1200, 1000, 800, 600, 400, 200, 100, 50, 30,
20, 10, 5, 2, 1,
],
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(
(_item, index) => `Day ${index + 1}`,
),
splitLine: {
lineStyle: {
type: 'solid',
width: 1,
},
show: true,
},
type: 'category',
},
yAxis: [
{
axisTick: {
show: false,
},
max: 3000,
splitArea: {
show: true,
},
splitNumber: 4,
type: 'value',
},
],
});
});
</script>
<template>
<EchartsUI ref="chartRef" />
</template>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,66 @@
<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: '2%',
left: 'center',
data: ['产品A', '产品B', '产品C', '产品D'],
},
series: [
{
animationDelay() {
return Math.random() * 100;
},
animationEasing: 'exponentialInOut',
animationType: 'scale',
avoidLabelOverlap: false,
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'],
data: [
{ name: '产品A', value: 1048 },
{ name: '产品B', value: 735 },
{ name: '产品C', value: 580 },
{ name: '产品D', value: 484 },
],
emphasis: {
label: {
fontSize: '12',
fontWeight: 'bold',
show: true,
},
},
itemStyle: {
// borderColor: '#fff',
borderRadius: 10,
borderWidth: 2,
},
label: {
position: 'center',
show: false,
},
labelLine: {
show: false,
},
name: '订单来源',
radius: ['40%', '65%'],
type: 'pie',
},
],
tooltip: {
trigger: 'item',
},
});
});
</script>
<template>
<EchartsUI ref="chartRef" />
</template>

View File

@@ -0,0 +1,56 @@
<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({
grid: {
bottom: 0,
containLabel: true,
left: '1%',
right: '1%',
top: '2%',
},
series: [
{
barMaxWidth: 80,
data: [
30, 20, 33, 50, 32, 42, 32, 21, 30, 51, 60, 32, 48, 40, 35, 28, 22,
18, 15, 10, 8, 6, 4, 2, 1, 1, 0, 0, 0, 0,
],
type: 'bar',
name: '订单数',
},
],
tooltip: {
axisPointer: {
lineStyle: {
width: 1,
},
},
trigger: 'axis',
},
xAxis: {
data: Array.from({ length: 30 }).map(
(_item, index) => `Day ${index + 1}`,
),
type: 'category',
},
yAxis: {
max: 80,
splitNumber: 4,
type: 'value',
},
});
});
</script>
<template>
<EchartsUI ref="chartRef" />
</template>

View File

@@ -0,0 +1,102 @@
<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 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';
const overviewItems: AnalysisOverviewItem[] = [
{
icon: SvgCardIcon,
title: '平台用户数',
totalTitle: '总用户数',
totalValue: 120_000,
value: 2000,
},
{
icon: SvgCakeIcon,
title: '推广访问量',
totalTitle: '总推广访问量',
totalValue: 500_000,
value: 20_000,
},
{
icon: SvgDownloadIcon,
title: '产品数量',
totalTitle: '总产品数量',
totalValue: 120,
value: 8,
},
{
icon: SvgBellIcon,
title: '代理数量',
totalTitle: '总代理数量',
totalValue: 5000,
value: 500,
},
];
const chartTabs: TabOption[] = [
{
label: '推广访问趋势',
value: 'trends',
},
{
label: '订单趋势',
value: 'visits',
},
];
</script>
<template>
<div class="p-5">
<div class="mb-4 ml-4 text-lg text-gray-500">
该数据为演示模拟生成不为真实数据
</div>
<AnalysisOverview :items="overviewItems" />
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
<template #trends>
<AnalyticsTrends />
</template>
<template #visits>
<AnalyticsVisits />
</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>

View 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>

View 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,
},
];
}

View 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>

View 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>

View File

@@ -0,0 +1,219 @@
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: '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,
},
{
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: '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: 'RangePicker',
fieldName: 'create_time',
label: '创建时间',
},
{
component: 'RangePicker',
fieldName: 'pay_time',
label: '支付时间',
},
{
component: 'RangePicker',
fieldName: 'refund_time',
label: '退款时间',
},
];
}

View 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>

View 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>

View 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,
),
},
];
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,234 @@
<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 = route.params.id as string;
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>

View 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,
},
];
}

View 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>

View 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>

View File

@@ -0,0 +1,94 @@
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',
},
];
}
// 搜索表单配置
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: '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,
},
];
}

View 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>

View File

@@ -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>

View File

@@ -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>

View 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,
},
];
}

View 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>

View File

@@ -0,0 +1,383 @@
<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,
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 });
message.success('保存成功');
emit('success');
modalApi.close(); // 保存成功后关闭Modal
return true;
} catch {
message.error('保存失败');
return false;
}
},
});
const loading = ref(false);
const tempFeatureList = ref<TempFeatureItem[]>([]);
// 表格配置
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;
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;
});
},
});
}
</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-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>

View File

@@ -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>

View 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,
},
];
}

View 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>

View File

@@ -0,0 +1,74 @@
<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, 'id' | 'create_time' | '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>

View 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,
},
];
}

View 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>

View 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>

View 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,
},
];
}

View 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>

View 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>

View 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,
},
];
}

View 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 Form from './modules/form.vue';
import ApiPermissions from './modules/api-permissions.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 'delete': {
onDelete(e.row);
break;
}
case 'edit': {
onEdit(e.row);
break;
}
case 'api-permissions': {
onApiPermissions(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>

View File

@@ -0,0 +1,201 @@
<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<string[]>([]);
const formData = ref<SystemRoleApi.SystemRoleItem>();
const roleId = ref<string>();
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>

View 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>

View 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,
},
];
}

View 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>

View 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>

View File

@@ -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>