init project

This commit is contained in:
caiyuchao
2025-05-16 14:52:30 +08:00
commit 1d6f7521c4
1496 changed files with 134863 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,173 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import type { AuthApi } from '#/api';
import { computed, onMounted, ref } from 'vue';
import { AuthenticationCodeLogin, z } from '@vben/common-ui';
import { isTenantEnable } from '@vben/hooks';
import { $t } from '@vben/locales';
import { useAccessStore } from '@vben/stores';
import { message } from 'ant-design-vue';
import { sendSmsCode } from '#/api';
import { getTenantByWebsite, getTenantSimpleList } from '#/api/core/auth';
import { useAuthStore } from '#/store';
defineOptions({ name: 'CodeLogin' });
const authStore = useAuthStore();
const accessStore = useAccessStore();
const tenantEnable = isTenantEnable();
const loading = ref(false);
const CODE_LENGTH = 4;
const loginRef = ref();
/** 获取租户列表,并默认选中 */
const tenantList = ref<AuthApi.TenantResult[]>([]); // 租户列表
async function fetchTenantList() {
if (!tenantEnable) {
return;
}
try {
// 获取租户列表、域名对应租户
const websiteTenantPromise = getTenantByWebsite(window.location.hostname);
tenantList.value = await getTenantSimpleList();
// 选中租户:域名 > store 中的租户 > 首个租户
let tenantId: null | number = null;
const websiteTenant = await websiteTenantPromise;
if (websiteTenant?.id) {
tenantId = websiteTenant.id;
}
// 如果没有从域名获取到租户,尝试从 store 中获取
if (!tenantId && accessStore.tenantId) {
tenantId = accessStore.tenantId;
}
// 如果还是没有租户,使用列表中的第一个
if (!tenantId && tenantList.value?.[0]?.id) {
tenantId = tenantList.value[0].id;
}
// 设置选中的租户编号
accessStore.setTenantId(tenantId);
loginRef.value.getFormApi().setFieldValue('tenantId', tenantId?.toString());
} catch (error) {
console.error('获取租户列表失败:', error);
}
}
/** 组件挂载时获取租户信息 */
onMounted(() => {
fetchTenantList();
});
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenSelect',
componentProps: {
options: tenantList.value.map((item) => ({
label: item.name,
value: item.id.toString(),
})),
placeholder: $t('authentication.tenantTip'),
},
fieldName: 'tenantId',
label: $t('authentication.tenant'),
rules: z.string().min(1, { message: $t('authentication.tenantTip') }),
dependencies: {
triggerFields: ['tenantId'],
if: tenantEnable,
trigger(values) {
if (values.tenantId) {
accessStore.setTenantId(Number(values.tenantId));
}
},
},
},
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.mobile'),
},
fieldName: 'mobile',
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'),
handleSendCode: async () => {
loading.value = true;
try {
const formApi = loginRef.value?.getFormApi();
if (!formApi) {
throw new Error('表单未准备好');
}
// 验证手机号
await formApi.validateField('mobile');
const isMobileValid = await formApi.isFieldValid('mobile');
if (!isMobileValid) {
throw new Error('请输入有效的手机号码');
}
// 发送验证码
const { mobile } = await formApi.getValues();
const scene = 21; // 场景:短信验证码登录
await sendSmsCode({ mobile, scene });
message.success('验证码发送成功');
} finally {
loading.value = false;
}
},
},
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>) {
try {
await authStore.authLogin('mobile', values);
} catch (error) {
console.error('Error in handleLogin:', error);
}
}
</script>
<template>
<AuthenticationCodeLogin
ref="loginRef"
:form-schema="formSchema"
:loading="loading"
@submit="handleLogin"
/>
</template>

View File

@@ -0,0 +1,216 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import type { AuthApi } from '#/api';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { AuthenticationForgetPassword, z } from '@vben/common-ui';
import { isTenantEnable } from '@vben/hooks';
import { $t } from '@vben/locales';
import { useAccessStore } from '@vben/stores';
import { message } from 'ant-design-vue';
import { sendSmsCode, smsResetPassword } from '#/api';
import { getTenantByWebsite, getTenantSimpleList } from '#/api/core/auth';
defineOptions({ name: 'ForgetPassword' });
const accessStore = useAccessStore();
const router = useRouter();
const tenantEnable = isTenantEnable();
const loading = ref(false);
const CODE_LENGTH = 4;
const forgetPasswordRef = ref();
/** 获取租户列表,并默认选中 */
const tenantList = ref<AuthApi.TenantResult[]>([]); // 租户列表
async function fetchTenantList() {
if (!tenantEnable) {
return;
}
try {
// 获取租户列表、域名对应租户
const websiteTenantPromise = getTenantByWebsite(window.location.hostname);
tenantList.value = await getTenantSimpleList();
// 选中租户:域名 > store 中的租户 > 首个租户
let tenantId: null | number = null;
const websiteTenant = await websiteTenantPromise;
if (websiteTenant?.id) {
tenantId = websiteTenant.id;
}
// 如果没有从域名获取到租户,尝试从 store 中获取
if (!tenantId && accessStore.tenantId) {
tenantId = accessStore.tenantId;
}
// 如果还是没有租户,使用列表中的第一个
if (!tenantId && tenantList.value?.[0]?.id) {
tenantId = tenantList.value[0].id;
}
// 设置选中的租户编号
accessStore.setTenantId(tenantId);
forgetPasswordRef.value
.getFormApi()
.setFieldValue('tenantId', tenantId?.toString());
} catch (error) {
console.error('获取租户列表失败:', error);
}
}
/** 组件挂载时获取租户信息 */
onMounted(() => {
fetchTenantList();
});
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenSelect',
componentProps: {
options: tenantList.value.map((item) => ({
label: item.name,
value: item.id.toString(),
})),
placeholder: $t('authentication.tenantTip'),
},
fieldName: 'tenantId',
label: $t('authentication.tenant'),
rules: z.string().min(1, { message: $t('authentication.tenantTip') }),
dependencies: {
triggerFields: ['tenantId'],
if: tenantEnable,
trigger(values) {
if (values.tenantId) {
accessStore.setTenantId(Number(values.tenantId));
}
},
},
},
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.mobile'),
},
fieldName: 'mobile',
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'),
handleSendCode: async () => {
loading.value = true;
try {
const formApi = forgetPasswordRef.value?.getFormApi();
if (!formApi) {
throw new Error('表单未准备好');
}
// 验证手机号
await formApi.validateField('mobile');
const isMobileValid = await formApi.isFieldValid('mobile');
if (!isMobileValid) {
throw new Error('请输入有效的手机号码');
}
// 发送验证码
const { mobile } = await formApi.getValues();
const scene = 23; // 场景:重置密码
await sendSmsCode({ mobile, scene });
message.success('验证码发送成功');
} finally {
loading.value = false;
}
},
},
fieldName: 'code',
label: $t('authentication.code'),
rules: z.string().length(CODE_LENGTH, {
message: $t('authentication.codeTip', [CODE_LENGTH]),
}),
},
{
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'),
},
];
});
/**
* 处理重置密码操作
* @param values 表单数据
*/
async function handleSubmit(values: Recordable<any>) {
loading.value = true;
try {
const { mobile, code, password } = values;
await smsResetPassword({ mobile, code, password });
message.success($t('authentication.resetPasswordSuccess'));
// 重置成功后跳转到首页
router.push('/');
} catch (error) {
console.error('重置密码失败:', error);
} finally {
loading.value = false;
}
}
</script>
<template>
<AuthenticationForgetPassword
ref="forgetPasswordRef"
:form-schema="formSchema"
:loading="loading"
@submit="handleSubmit"
/>
</template>

View File

@@ -0,0 +1,192 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { AuthApi } from '#/api/core/auth';
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { AuthenticationLogin, Verification, z } from '@vben/common-ui';
import { isCaptchaEnable, isTenantEnable } from '@vben/hooks';
import { $t } from '@vben/locales';
import { useAccessStore } from '@vben/stores';
import {
checkCaptcha,
getCaptcha,
getTenantByWebsite,
getTenantSimpleList,
socialAuthRedirect,
} from '#/api/core/auth';
import { useAuthStore } from '#/store';
defineOptions({ name: 'Login' });
const { query } = useRoute();
const authStore = useAuthStore();
const accessStore = useAccessStore();
const tenantEnable = isTenantEnable();
const captchaEnable = isCaptchaEnable();
const loginRef = ref();
const verifyRef = ref();
const captchaType = 'blockPuzzle'; // 验证码类型:'blockPuzzle' | 'clickWord'
/** 获取租户列表,并默认选中 */
const tenantList = ref<AuthApi.TenantResult[]>([]); // 租户列表
async function fetchTenantList() {
if (!tenantEnable) {
return;
}
try {
// 获取租户列表、域名对应租户
const websiteTenantPromise = getTenantByWebsite(window.location.hostname);
tenantList.value = await getTenantSimpleList();
// 选中租户:域名 > store 中的租户 > 首个租户
let tenantId: null | number = null;
const websiteTenant = await websiteTenantPromise;
if (websiteTenant?.id) {
tenantId = websiteTenant.id;
}
// 如果没有从域名获取到租户,尝试从 store 中获取
if (!tenantId && accessStore.tenantId) {
tenantId = accessStore.tenantId;
}
// 如果还是没有租户,使用列表中的第一个
if (!tenantId && tenantList.value?.[0]?.id) {
tenantId = tenantList.value[0].id;
}
// 设置选中的租户编号
accessStore.setTenantId(tenantId);
loginRef.value.getFormApi().setFieldValue('tenantId', tenantId?.toString());
} catch (error) {
console.error('获取租户列表失败:', error);
}
}
/** 处理登录 */
async function handleLogin(values: any) {
// 如果开启验证码,则先验证验证码
if (captchaEnable) {
verifyRef.value.show();
return;
}
// 无验证码,直接登录
await authStore.authLogin('username', values);
}
/** 验证码通过,执行登录 */
async function handleVerifySuccess({ captchaVerification }: any) {
try {
await authStore.authLogin('username', {
...(await loginRef.value.getFormApi().getValues()),
captchaVerification,
});
} catch (error) {
console.error('Error in handleLogin:', error);
}
}
/** 处理第三方登录 */
const redirect = query?.redirect;
async function handleThirdLogin(type: number) {
if (type <= 0) {
return;
}
try {
// 计算 redirectUri
// tricky: type、redirect 需要先 encode 一次,否则钉钉回调会丢失。配合 social-login.vue#getUrlValue() 使用
const redirectUri = `${
location.origin
}/auth/social-login?${encodeURIComponent(
`type=${type}&redirect=${redirect || '/'}`,
)}`;
// 进行跳转
window.location.href = await socialAuthRedirect(type, redirectUri);
} catch (error) {
console.error('第三方登录处理失败:', error);
}
}
/** 组件挂载时获取租户信息 */
onMounted(() => {
fetchTenantList();
});
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenSelect',
componentProps: {
options: tenantList.value.map((item) => ({
label: item.name,
value: item.id.toString(),
})),
placeholder: $t('authentication.tenantTip'),
},
fieldName: 'tenantId',
label: $t('authentication.tenant'),
rules: z.string().min(1, { message: $t('authentication.tenantTip') }),
dependencies: {
triggerFields: ['tenantId'],
if: tenantEnable,
trigger(values) {
if (values.tenantId) {
accessStore.setTenantId(Number(values.tenantId));
}
},
},
},
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.usernameTip'),
},
fieldName: 'username',
label: $t('authentication.username'),
rules: z
.string()
.min(1, { message: $t('authentication.usernameTip') })
.default(import.meta.env.VITE_APP_DEFAULT_USERNAME),
},
{
component: 'VbenInputPassword',
componentProps: {
placeholder: $t('authentication.passwordTip'),
},
fieldName: 'password',
label: $t('authentication.password'),
rules: z
.string()
.min(1, { message: $t('authentication.passwordTip') })
.default(import.meta.env.VITE_APP_DEFAULT_PASSWORD),
},
];
});
</script>
<template>
<div>
<AuthenticationLogin
ref="loginRef"
:form-schema="formSchema"
:loading="authStore.loginLoading"
@submit="handleLogin"
@third-login="handleThirdLogin"
/>
<Verification
ref="verifyRef"
v-if="captchaEnable"
:captcha-type="captchaType"
:check-captcha-api="checkCaptcha"
:get-captcha-api="getCaptcha"
:img-size="{ width: '400px', height: '200px' }"
mode="pop"
@on-success="handleVerifySuccess"
/>
</div>
</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,221 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { AuthApi } from '#/api/core/auth';
import { computed, h, onMounted, ref } from 'vue';
import { AuthenticationRegister, Verification, z } from '@vben/common-ui';
import { isCaptchaEnable, isTenantEnable } from '@vben/hooks';
import { $t } from '@vben/locales';
import { useAccessStore } from '@vben/stores';
import {
checkCaptcha,
getCaptcha,
getTenantByWebsite,
getTenantSimpleList,
} from '#/api/core/auth';
import { useAuthStore } from '#/store';
defineOptions({ name: 'Register' });
const loading = ref(false);
const accessStore = useAccessStore();
const authStore = useAuthStore();
const tenantEnable = isTenantEnable();
const captchaEnable = isCaptchaEnable();
const registerRef = ref();
const verifyRef = ref();
const captchaType = 'blockPuzzle'; // 验证码类型:'blockPuzzle' | 'clickWord'
/** 获取租户列表,并默认选中 */
const tenantList = ref<AuthApi.TenantResult[]>([]); // 租户列表
async function fetchTenantList() {
if (!tenantEnable) {
return;
}
try {
// 获取租户列表、域名对应租户
const websiteTenantPromise = getTenantByWebsite(window.location.hostname);
tenantList.value = await getTenantSimpleList();
// 选中租户:域名 > store 中的租户 > 首个租户
let tenantId: null | number = null;
const websiteTenant = await websiteTenantPromise;
if (websiteTenant?.id) {
tenantId = websiteTenant.id;
}
// 如果没有从域名获取到租户,尝试从 store 中获取
if (!tenantId && accessStore.tenantId) {
tenantId = accessStore.tenantId;
}
// 如果还是没有租户,使用列表中的第一个
if (!tenantId && tenantList.value?.[0]?.id) {
tenantId = tenantList.value[0].id;
}
// 设置选中的租户编号
accessStore.setTenantId(tenantId);
registerRef.value
.getFormApi()
.setFieldValue('tenantId', tenantId?.toString());
} catch (error) {
console.error('获取租户列表失败:', error);
}
}
/** 执行注册 */
async function handleRegister(values: any) {
// 如果开启验证码,则先验证验证码
if (captchaEnable) {
verifyRef.value.show();
return;
}
// 无验证码,直接登录
await authStore.authLogin('register', values);
}
/** 验证码通过,执行注册 */
const handleVerifySuccess = async ({ captchaVerification }: any) => {
try {
await authStore.authLogin('register', {
...(await registerRef.value.getFormApi().getValues()),
captchaVerification,
});
} catch (error) {
console.error('Error in handleRegister:', error);
}
};
/** 组件挂载时获取租户信息 */
onMounted(() => {
fetchTenantList();
});
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenSelect',
componentProps: {
options: tenantList.value.map((item) => ({
label: item.name,
value: item.id.toString(),
})),
placeholder: $t('authentication.tenantTip'),
},
fieldName: 'tenantId',
label: $t('authentication.tenant'),
rules: z.string().min(1, { message: $t('authentication.tenantTip') }),
dependencies: {
triggerFields: ['tenantId'],
if: tenantEnable,
trigger(values) {
if (values.tenantId) {
accessStore.setTenantId(Number(values.tenantId));
}
},
},
},
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.usernameTip'),
},
fieldName: 'username',
label: $t('authentication.username'),
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
},
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.nicknameTip'),
},
fieldName: 'nickname',
label: $t('authentication.nickname'),
rules: z.string().min(1, { message: $t('authentication.nicknameTip') }),
},
{
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'),
}),
},
];
});
</script>
<template>
<div>
<AuthenticationRegister
ref="registerRef"
:form-schema="formSchema"
:loading="loading"
@submit="handleRegister"
/>
<Verification
ref="verifyRef"
v-if="captchaEnable"
:captcha-type="captchaType"
:check-captcha-api="checkCaptcha"
:get-captcha-api="getCaptcha"
:img-size="{ width: '400px', height: '200px' }"
mode="pop"
@on-success="handleVerifySuccess"
/>
</div>
</template>

View File

@@ -0,0 +1,215 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { AuthApi } from '#/api/core/auth';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { AuthenticationLogin, Verification, z } from '@vben/common-ui';
import { isCaptchaEnable, isTenantEnable } from '@vben/hooks';
import { $t } from '@vben/locales';
import { useAccessStore } from '@vben/stores';
import {
checkCaptcha,
getCaptcha,
getTenantByWebsite,
getTenantSimpleList,
} from '#/api/core/auth';
import { useAuthStore } from '#/store';
defineOptions({ name: 'SocialLogin' });
const authStore = useAuthStore();
const accessStore = useAccessStore();
const { query } = useRoute();
const router = useRouter();
const tenantEnable = isTenantEnable();
const captchaEnable = isCaptchaEnable();
const loginRef = ref();
const verifyRef = ref();
const captchaType = 'blockPuzzle'; // 验证码类型:'blockPuzzle' | 'clickWord'
/** 获取租户列表,并默认选中 */
const tenantList = ref<AuthApi.TenantResult[]>([]); // 租户列表
async function fetchTenantList() {
if (!tenantEnable) {
return;
}
try {
// 获取租户列表、域名对应租户
const websiteTenantPromise = getTenantByWebsite(window.location.hostname);
tenantList.value = await getTenantSimpleList();
// 选中租户:域名 > store 中的租户 > 首个租户
let tenantId: null | number = null;
const websiteTenant = await websiteTenantPromise;
if (websiteTenant?.id) {
tenantId = websiteTenant.id;
}
// 如果没有从域名获取到租户,尝试从 store 中获取
if (!tenantId && accessStore.tenantId) {
tenantId = accessStore.tenantId;
}
// 如果还是没有租户,使用列表中的第一个
if (!tenantId && tenantList.value?.[0]?.id) {
tenantId = tenantList.value[0].id;
}
// 设置选中的租户编号
accessStore.setTenantId(tenantId);
loginRef.value.getFormApi().setFieldValue('tenantId', tenantId);
} catch (error) {
console.error('获取租户列表失败:', error);
}
}
/** 尝试登录当账号已经绑定socialLogin 会直接获得 token */
const socialType = Number(getUrlValue('type'));
const redirect = getUrlValue('redirect');
const socialCode = query?.code as string;
const socialState = query?.state as string;
async function tryLogin() {
// 用于登录后,基于 redirect 的重定向
if (redirect) {
await router.replace({
query: {
...query,
redirect: encodeURIComponent(redirect),
},
});
}
// 尝试登录
await authStore.authLogin('social', {
type: socialType,
code: socialCode,
state: socialState,
});
}
/** 处理登录 */
async function handleLogin(values: any) {
// 如果开启验证码,则先验证验证码
if (captchaEnable) {
verifyRef.value.show();
return;
}
// 无验证码,直接登录
await authStore.authLogin('username', {
...values,
socialType,
socialCode,
socialState,
});
}
/** 验证码通过,执行登录 */
async function handleVerifySuccess({ captchaVerification }: any) {
try {
await authStore.authLogin('username', {
...(await loginRef.value.getFormApi().getValues()),
captchaVerification,
socialType,
socialCode,
socialState,
});
} catch (error) {
console.error('Error in handleLogin:', error);
}
}
/** tricky: 配合 login.vue 中redirectUri 需要对参数进行 encode需要在回调后进行decode */
function getUrlValue(key: string): string {
const url = new URL(decodeURIComponent(location.href));
return url.searchParams.get(key) ?? '';
}
/** 组件挂载时获取租户信息 */
onMounted(async () => {
await fetchTenantList();
await tryLogin();
});
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenSelect',
componentProps: {
options: tenantList.value.map((item) => ({
label: item.name,
value: item.id.toString(),
})),
placeholder: $t('authentication.tenantTip'),
},
fieldName: 'tenantId',
label: $t('authentication.tenant'),
rules: z.string().min(1, { message: $t('authentication.tenantTip') }),
dependencies: {
triggerFields: ['tenantId'],
if: tenantEnable,
trigger(values) {
if (values.tenantId) {
accessStore.setTenantId(Number(values.tenantId));
}
},
},
},
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.usernameTip'),
},
fieldName: 'username',
label: $t('authentication.username'),
rules: z
.string()
.min(1, { message: $t('authentication.usernameTip') })
.default(import.meta.env.VITE_APP_DEFAULT_USERNAME),
},
{
component: 'VbenInputPassword',
componentProps: {
placeholder: $t('authentication.passwordTip'),
},
fieldName: 'password',
label: $t('authentication.password'),
rules: z
.string()
.min(1, { message: $t('authentication.passwordTip') })
.default(import.meta.env.VITE_APP_DEFAULT_PASSWORD),
},
];
});
</script>
<template>
<div>
<AuthenticationLogin
ref="loginRef"
:form-schema="formSchema"
:loading="authStore.loginLoading"
:show-code-login="false"
:show-qrcode-login="false"
:show-third-party-login="false"
:show-register="false"
@submit="handleLogin"
/>
<Verification
ref="verifyRef"
v-if="captchaEnable"
:captcha-type="captchaType"
:check-captcha-api="checkCaptcha"
:get-captcha-api="getCaptcha"
:img-size="{ width: '400px', height: '200px' }"
mode="pop"
@on-success="handleVerifySuccess"
/>
</div>
</template>

View File

@@ -0,0 +1,221 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import { AuthenticationAuthTitle, VbenButton } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { authorize, getAuthorize } from '#/api/system/oauth2/open';
defineOptions({ name: 'SSOLogin' });
const { query } = useRoute(); // 路由参数
const client = ref({
name: '',
logo: '',
}); // 客户端信息
const queryParams = reactive({
responseType: '',
clientId: '',
redirectUri: '',
state: '',
scopes: [] as string[], // 优先从 query 参数获取;如果未传递,从后端获取
}); // URL 上的 client_id、scope 等参数
const loading = ref(false); // 表单是否提交中
/** 初始化授权信息 */
async function init() {
// 防止在没有登录的情况下循环弹窗
if (query.client_id === undefined) {
return;
}
// 解析参数
// 例如说【自动授权不通过】client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read%20user.write
// 例如说【自动授权通过】client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read
queryParams.responseType = query.response_type as string;
queryParams.clientId = query.client_id as string;
queryParams.redirectUri = query.redirect_uri as string;
queryParams.state = query.state as string;
if (query.scope) {
queryParams.scopes = (query.scope as string).split(' ');
}
// 如果有 scope 参数,先执行一次自动授权,看看是否之前都授权过了。
if (queryParams.scopes.length > 0) {
const data = await doAuthorize(true, queryParams.scopes, []);
if (data) {
location.href = data;
return;
}
}
// 1.1 获取授权页的基本信息
const data = await getAuthorize(queryParams.clientId);
client.value = data.client;
// 1.2 解析 scope
let scopes;
// 如果 params.scope 非空,则过滤下返回的 scopes
if (queryParams.scopes.length > 0) {
scopes = data.scopes.filter((scope) =>
queryParams.scopes.includes(scope.key),
);
// 如果 params.scope 为空,则使用返回的 scopes 设置它
} else {
scopes = data.scopes;
queryParams.scopes = scopes.map((scope) => scope.key);
}
// 2.设置表单的初始值
formApi.setFieldValue(
'scopes',
scopes.filter((scope) => scope.value).map((scope) => scope.key),
);
}
/** 处理授权的提交 */
async function handleSubmit(approved: boolean) {
// 计算 checkedScopes + uncheckedScopes
let checkedScopes: string[];
let uncheckedScopes: string[];
if (approved) {
// 同意授权,按照用户的选择
const res = await formApi.getValues();
checkedScopes = res.scopes;
uncheckedScopes = queryParams.scopes.filter(
(item) => !checkedScopes.includes(item),
);
} else {
// 拒绝,则都是取消
checkedScopes = [];
uncheckedScopes = queryParams.scopes;
}
// 提交授权的请求
loading.value = true;
try {
const data = await doAuthorize(false, checkedScopes, uncheckedScopes);
if (!data) {
return;
}
// 跳转授权成功后的回调地址
location.href = data;
} finally {
loading.value = false;
}
}
/** 调用授权 API 接口 */
const doAuthorize = (
autoApprove: boolean,
checkedScopes: string[],
uncheckedScopes: string[],
) => {
return authorize(
queryParams.responseType,
queryParams.clientId,
queryParams.redirectUri,
queryParams.state,
autoApprove,
checkedScopes,
uncheckedScopes,
);
};
/** 格式化 scope 文本 */
function formatScope(scope: string) {
// 格式化 scope 授权范围,方便用户理解。
// 这里仅仅是一个 demo可以考虑录入到字典数据中例如说字典类型 "system_oauth2_scope",它的每个 scope 都是一条字典数据。
switch (scope) {
case 'user.read': {
return '访问你的个人信息';
}
case 'user.write': {
return '修改你的个人信息';
}
default: {
return scope;
}
}
}
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'scopes',
label: '授权范围',
component: 'CheckboxGroup',
componentProps: {
options: queryParams.scopes.map((scope) => ({
label: formatScope(scope),
value: scope,
})),
class: 'flex flex-col gap-2',
},
},
];
});
const [Form, formApi] = useVbenForm(
reactive({
commonConfig: {
hideLabel: true,
hideRequiredMark: true,
},
schema: formSchema,
showDefaultActions: false,
}),
);
/** 初始化 */
onMounted(() => {
init();
});
</script>
<template>
<div @keydown.enter.prevent="handleSubmit(true)">
<AuthenticationAuthTitle>
<slot name="title">
{{ `${client.name} 👋🏻` }}
</slot>
<template #desc>
<span class="text-muted-foreground">
此第三方应用请求获得以下权限
</span>
</template>
</AuthenticationAuthTitle>
<Form />
<div class="flex gap-2">
<VbenButton
:class="{
'cursor-wait': loading,
}"
:loading="loading"
aria-label="login"
class="w-2/3"
@click="handleSubmit(true)"
>
同意授权
</VbenButton>
<VbenButton
:class="{
'cursor-wait': loading,
}"
:loading="loading"
aria-label="login"
class="w-1/3"
variant="outline"
@click="handleSubmit(false)"
>
拒绝
</VbenButton>
</div>
</div>
</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,65 @@
<script setup lang="ts">
import type { SystemUserProfileApi } from '#/api/system/user/profile';
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Card, Tabs } from 'ant-design-vue';
import { getUserProfile } from '#/api/system/user/profile';
import { useAuthStore } from '#/store';
import BaseInfo from './modules/base-info.vue';
import ProfileUser from './modules/profile-user.vue';
import ResetPwd from './modules/reset-pwd.vue';
import UserSocial from './modules/user-social.vue';
const authStore = useAuthStore();
const activeName = ref('basicInfo');
/** 加载个人信息 */
const profile = ref<SystemUserProfileApi.UserProfileRespVO>();
async function loadProfile() {
profile.value = await getUserProfile();
}
/** 刷新个人信息 */
async function refreshProfile() {
// 加载个人信息
await loadProfile();
// 更新 store
await authStore.fetchUserInfo();
}
/** 初始化 */
onMounted(loadProfile);
</script>
<template>
<Page auto-content-height>
<div class="flex">
<!-- 左侧 个人信息 -->
<Card class="w-2/5" title="个人信息">
<ProfileUser :profile="profile" @success="refreshProfile" />
</Card>
<!-- 右侧 标签页 -->
<Card class="ml-3 w-3/5">
<Tabs v-model:active-key="activeName" class="-mt-4">
<Tabs.TabPane key="basicInfo" tab="基本设置">
<BaseInfo :profile="profile" @success="refreshProfile" />
</Tabs.TabPane>
<Tabs.TabPane key="resetPwd" tab="密码设置">
<ResetPwd />
</Tabs.TabPane>
<Tabs.TabPane key="userSocial" tab="社交绑定" force-render>
<UserSocial @update:active-name="activeName = $event" />
</Tabs.TabPane>
<!-- TODO @芋艿在线设备 -->
</Tabs>
</Card>
</div>
</Page>
</template>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import type { SystemUserProfileApi } from '#/api/system/user/profile';
import { watch } from 'vue';
import { $t } from '@vben/locales';
import { message } from 'ant-design-vue';
import { useVbenForm, z } from '#/adapter/form';
import { updateUserProfile } from '#/api/system/user/profile';
import { DICT_TYPE, getDictOptions } from '#/utils';
const props = defineProps<{
profile?: SystemUserProfileApi.UserProfileRespVO;
}>();
const emit = defineEmits<{
(e: 'success'): void;
}>();
const [Form, formApi] = useVbenForm({
commonConfig: {
labelWidth: 70,
},
schema: [
{
label: '用户昵称',
fieldName: 'nickname',
component: 'Input',
componentProps: {
placeholder: '请输入用户昵称',
},
rules: 'required',
},
{
label: '用户手机',
fieldName: 'mobile',
component: 'Input',
componentProps: {
placeholder: '请输入用户手机',
},
rules: z.string(),
},
{
label: '用户邮箱',
fieldName: 'email',
component: 'Input',
componentProps: {
placeholder: '请输入用户邮箱',
},
rules: z.string().email('请输入正确的邮箱'),
},
{
label: '用户性别',
fieldName: 'sex',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number(),
},
],
resetButtonOptions: {
show: false,
},
submitButtonOptions: {
content: '更新信息',
},
handleSubmit,
});
async function handleSubmit(values: Recordable<any>) {
try {
formApi.setLoading(true);
// 提交表单
await updateUserProfile(values as SystemUserProfileApi.UpdateProfileReqVO);
// 关闭并提示
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} catch (error) {
console.error(error);
} finally {
formApi.setLoading(false);
}
}
/** 监听 profile 变化 */
watch(
() => props.profile,
(newProfile) => {
if (newProfile) {
formApi.setValues(newProfile);
}
},
{ immediate: true },
);
</script>
<template>
<div class="mt-16px md:w-full lg:w-1/2 2xl:w-2/5">
<Form />
</div>
</template>

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
import type { SystemUserProfileApi } from '#/api/system/user/profile';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { preferences } from '@vben/preferences';
import { formatDateTime } from '@vben/utils';
import { Descriptions, DescriptionsItem, Tooltip } from 'ant-design-vue';
import { updateUserProfile } from '#/api/system/user/profile';
import { CropperAvatar } from '#/components/cropper';
import { useUpload } from '#/components/upload/use-upload';
const props = defineProps<{
profile?: SystemUserProfileApi.UserProfileRespVO;
}>();
const emit = defineEmits<{
(e: 'success'): void;
}>();
const avatar = computed(
() => props.profile?.avatar || preferences.app.defaultAvatar,
);
async function handelUpload({
file,
filename,
}: {
file: Blob;
filename: string;
}) {
// 1. 上传头像,获取 URL
const { httpRequest } = useUpload();
// 将 Blob 转换为 File
const fileObj = new File([file], filename, { type: file.type });
const avatar = await httpRequest(fileObj);
// 2. 更新用户头像
await updateUserProfile({ avatar });
}
</script>
<template>
<div v-if="profile">
<div class="flex flex-col items-center">
<Tooltip title="点击上传头像">
<CropperAvatar
:show-btn="false"
:upload-api="handelUpload"
:value="avatar"
:width="120"
@change="emit('success')"
/>
</Tooltip>
</div>
<div class="mt-8">
<Descriptions :column="2">
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:user-outlined" class="mr-1" />
用户账号
</div>
</template>
{{ profile.username }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon
icon="ant-design:user-switch-outlined"
class="mr-1"
/>
所属角色
</div>
</template>
{{ profile.roles.map((role) => role.name).join(',') }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:phone-outlined" class="mr-1" />
手机号码
</div>
</template>
{{ profile.mobile }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:mail-outlined" class="mr-1" />
用户邮箱
</div>
</template>
{{ profile.email }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:team-outlined" class="mr-1" />
所属部门
</div>
</template>
{{ profile.dept?.name }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon
icon="ant-design:usergroup-add-outlined"
class="mr-1"
/>
所属岗位
</div>
</template>
{{ profile.posts.map((post) => post.name).join(',') }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon
icon="ant-design:clock-circle-outlined"
class="mr-1"
/>
创建时间
</div>
</template>
{{ formatDateTime(profile.createTime) }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:login-outlined" class="mr-1" />
登录时间
</div>
</template>
{{ formatDateTime(profile.loginDate) }}
</DescriptionsItem>
</Descriptions>
</div>
</div>
</template>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import { $t } from '@vben/locales';
import { message } from 'ant-design-vue';
import { useVbenForm, z } from '#/adapter/form';
import { updateUserPassword } from '#/api/system/user/profile';
const [Form, formApi] = useVbenForm({
commonConfig: {
labelWidth: 70,
},
schema: [
{
component: 'InputPassword',
fieldName: 'oldPassword',
label: '旧密码',
rules: z
.string({ message: '请输入密码' })
.min(5, '密码长度不能少于 5 个字符')
.max(20, '密码长度不能超过 20 个字符'),
},
{
component: 'InputPassword',
dependencies: {
rules(values) {
return z
.string({ message: '请输入新密码' })
.min(5, '密码长度不能少于 5 个字符')
.max(20, '密码长度不能超过 20 个字符')
.refine(
(value) => value !== values.oldPassword,
'新旧密码不能相同',
);
},
triggerFields: ['newPassword', 'oldPassword'],
},
fieldName: 'newPassword',
label: '新密码',
rules: 'required',
},
{
component: 'InputPassword',
dependencies: {
rules(values) {
return z
.string({ message: '请输入确认密码' })
.min(5, '密码长度不能少于 5 个字符')
.max(20, '密码长度不能超过 20 个字符')
.refine(
(value) => value === values.newPassword,
'新密码和确认密码不一致',
);
},
triggerFields: ['newPassword', 'confirmPassword'],
},
fieldName: 'confirmPassword',
label: '确认密码',
rules: 'required',
},
],
resetButtonOptions: {
show: false,
},
submitButtonOptions: {
content: '修改密码',
},
handleSubmit,
});
async function handleSubmit(values: Recordable<any>) {
try {
formApi.setLoading(true);
// 提交表单
await updateUserPassword({
oldPassword: values.oldPassword,
newPassword: values.newPassword,
});
message.success($t('ui.actionMessage.operationSuccess'));
} catch (error) {
console.error(error);
} finally {
formApi.setLoading(false);
}
}
</script>
<template>
<div class="mt-[16px] md:w-full lg:w-1/2 2xl:w-2/5">
<Form />
</div>
</template>

View File

@@ -0,0 +1,214 @@
<script setup lang="tsx">
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemSocialUserApi } from '#/api/system/social/user';
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { confirm } from '@vben/common-ui';
import { Button, Card, Image, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { socialAuthRedirect } from '#/api/core/auth';
import {
getBindSocialUserList,
socialBind,
socialUnbind,
} from '#/api/system/social/user';
import { $t } from '#/locales';
import { DICT_TYPE, getDictLabel, SystemUserSocialTypeEnum } from '#/utils';
const emit = defineEmits<{
(e: 'update:activeName', v: string): void;
}>();
const route = useRoute();
/** 已经绑定的平台 */
const bindList = ref<SystemSocialUserApi.SocialUser[]>([]);
const allBindList = computed<any[]>(() => {
return Object.values(SystemUserSocialTypeEnum).map((social) => {
const socialUser = bindList.value.find((item) => item.type === social.type);
return {
...social,
socialUser,
};
});
});
function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'type',
title: '绑定平台',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_SOCIAL_TYPE },
},
},
{
field: 'openid',
title: '标识',
minWidth: 180,
},
{
field: 'nickname',
title: '昵称',
minWidth: 180,
},
{
field: 'operation',
title: '操作',
minWidth: 80,
align: 'center',
fixed: 'right',
slots: {
default: ({ row }: { row: SystemSocialUserApi.SocialUser }) => {
return (
<Button onClick={() => onUnbind(row)} type="link">
解绑
</Button>
);
},
},
},
];
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
minHeight: 0,
keepSource: true,
proxyConfig: {
ajax: {
query: async () => {
bindList.value = await getBindSocialUserList();
return bindList.value;
},
},
},
rowConfig: {
keyField: 'id',
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<SystemSocialUserApi.SocialUser>,
});
/** 解绑账号 */
function onUnbind(row: SystemSocialUserApi.SocialUser) {
confirm({
content: `确定解绑[${getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, row.type)}]平台的[${row.openid}]账号吗?`,
}).then(async () => {
await socialUnbind({ type: row.type, openid: row.openid });
// 提示成功
message.success($t('ui.actionMessage.operationSuccess'));
await gridApi.reload();
});
}
/** 绑定账号(跳转授权页面) */
async function onBind(bind: any) {
const type = bind.type;
if (type <= 0) {
return;
}
try {
// 计算 redirectUri
// tricky: type 需要先 encode 一次,否则钉钉回调会丢失。配合 getUrlValue() 使用
const redirectUri = `${location.origin}/profile?${encodeURIComponent(`type=${type}`)}`;
// 进行跳转
window.location.href = await socialAuthRedirect(type, redirectUri);
} catch (error) {
console.error('社交绑定处理失败:', error);
}
}
/** 监听路由变化,处理社交绑定回调 */
async function bindSocial() {
// 社交绑定
const type = Number(getUrlValue('type'));
const code = route.query.code as string;
const state = route.query.state as string;
if (!code) {
return;
}
await socialBind({ type, code, state });
// 提示成功
message.success('绑定成功');
emit('update:activeName', 'userSocial');
await gridApi.reload();
// 清理 URL 参数,避免刷新重复触发
window.history.replaceState({}, '', location.pathname);
}
// TODO @芋艿:后续搞到 util 里;
// 双层 encode 需要在回调后进行 decode
function getUrlValue(key: string): string {
const url = new URL(decodeURIComponent(location.href));
return url.searchParams.get(key) ?? '';
}
/** 初始化 */
onMounted(() => {
bindSocial();
});
</script>
<template>
<div class="flex flex-col">
<Grid />
<div class="pb-3">
<div
class="grid grid-cols-1 gap-2 px-2 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3"
>
<Card v-for="item in allBindList" :key="item.type" class="!mb-2">
<div class="flex w-full items-center gap-4">
<Image
:src="item.img"
:width="40"
:height="40"
:alt="item.title"
:preview="false"
/>
<div class="flex flex-1 items-center justify-between">
<div class="flex flex-col">
<h4
class="mb-[4px] text-[14px] text-black/85 dark:text-white/85"
>
{{ getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) }}
</h4>
<span class="text-black/45 dark:text-white/45">
<template v-if="item.socialUser">
{{ item.socialUser?.nickname || item.socialUser?.openid }}
</template>
<template v-else>
绑定{{
getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type)
}}账号
</template>
</span>
</div>
<Button
:disabled="!!item.socialUser"
size="small"
type="link"
@click="onBind(item)"
>
{{ item.socialUser ? '已绑定' : '绑定' }}
</Button>
</div>
</div>
</Card>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,895 @@
import type { Ref } from 'vue';
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmContractApi } from '#/api/crm/contract';
import type { CrmReceivableApi } from '#/api/crm/receivable';
import { useAccess } from '@vben/access';
import { DICT_TYPE } from '#/utils';
const { hasAccessByCodes } = useAccess();
export interface LeftSideItem {
name: string;
menu: string;
count: Ref<number>;
}
/** 跟进状态 */
export const FOLLOWUP_STATUS = [
{ label: '待跟进', value: false },
{ label: '已跟进', value: true },
];
/** 归属范围 */
export const SCENE_TYPES = [
{ label: '我负责的', value: 1 },
{ label: '我参与的', value: 2 },
{ label: '下属负责的', value: 3 },
];
/** 联系状态 */
export const CONTACT_STATUS = [
{ label: '今日需联系', value: 1 },
{ label: '已逾期', value: 2 },
{ label: '已联系', value: 3 },
];
/** 审批状态 */
export const AUDIT_STATUS = [
{ label: '待审批', value: 10 },
{ label: '审核通过', value: 20 },
{ label: '审核不通过', value: 30 },
];
/** 回款提醒类型 */
export const RECEIVABLE_REMIND_TYPE = [
{ label: '待回款', value: 1 },
{ label: '已逾期', value: 2 },
{ label: '已回款', value: 3 },
];
/** 合同过期状态 */
export const CONTRACT_EXPIRY_TYPE = [
{ label: '即将过期', value: 1 },
{ label: '已过期', value: 2 },
];
export const useLeftSides = (
customerTodayContactCount: Ref<number>,
clueFollowCount: Ref<number>,
customerFollowCount: Ref<number>,
customerPutPoolRemindCount: Ref<number>,
contractAuditCount: Ref<number>,
contractRemindCount: Ref<number>,
receivableAuditCount: Ref<number>,
receivablePlanRemindCount: Ref<number>,
): LeftSideItem[] => {
return [
{
name: '今日需联系客户',
menu: 'customerTodayContact',
count: customerTodayContactCount,
},
{
name: '分配给我的线索',
menu: 'clueFollow',
count: clueFollowCount,
},
{
name: '分配给我的客户',
menu: 'customerFollow',
count: customerFollowCount,
},
{
name: '待进入公海的客户',
menu: 'customerPutPoolRemind',
count: customerPutPoolRemindCount,
},
{
name: '待审核合同',
menu: 'contractAudit',
count: contractAuditCount,
},
{
name: '待审核回款',
menu: 'receivableAudit',
count: receivableAuditCount,
},
{
name: '待回款提醒',
menu: 'receivablePlanRemind',
count: receivablePlanRemindCount,
},
{
name: '即将到期的合同',
menu: 'contractRemind',
count: contractRemindCount,
},
];
};
/** 分配给我的线索 列表的搜索表单 */
export function useClueFollowFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'followUpStatus',
label: '状态',
component: 'Select',
componentProps: {
allowClear: true,
options: FOLLOWUP_STATUS,
},
defaultValue: false,
},
];
}
/** 分配给我的线索 列表的字段 */
export function useClueFollowColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '线索名称',
minWidth: 160,
fixed: 'left',
slots: { default: 'name' },
},
{
field: 'source',
title: '线索来源',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_SOURCE },
},
},
{
field: 'mobile',
title: '手机',
minWidth: 120,
},
{
field: 'telephone',
title: '电话',
minWidth: 130,
},
{
field: 'email',
title: '邮箱',
minWidth: 180,
},
{
field: 'detailAddress',
title: '地址',
minWidth: 180,
},
{
field: 'industryId',
title: '客户行业',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY },
},
},
{
field: 'level',
title: '客户级别',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_LEVEL },
},
},
{
field: 'contactNextTime',
title: '下次联系时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'contactLastTime',
title: '最后跟进时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'contactLastContent',
title: '最后跟进记录',
minWidth: 200,
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 100,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 100,
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'creatorName',
title: '创建人',
minWidth: 100,
},
];
}
/** 合同审核列表的搜索表单 */
export function useContractAuditFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'auditStatus',
label: '合同状态',
component: 'Select',
componentProps: {
allowClear: true,
options: AUDIT_STATUS,
},
defaultValue: 10,
},
];
}
/** 合同提醒列表的搜索表单 */
export function useContractRemindFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'expiryType',
label: '到期状态',
component: 'Select',
componentProps: {
allowClear: true,
options: CONTRACT_EXPIRY_TYPE,
},
defaultValue: 1,
},
];
}
/** 合同审核列表的字段 */
export function useContractColumns<T = CrmContractApi.Contract>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'no',
title: '合同编号',
minWidth: 160,
fixed: 'left',
},
{
field: 'name',
title: '合同名称',
minWidth: 160,
slots: {
default: 'name',
},
},
{
field: 'customerName',
title: '客户名称',
minWidth: 160,
slots: {
default: 'customerName',
},
},
{
field: 'businessName',
title: '商机名称',
minWidth: 160,
slots: {
default: 'businessName',
},
},
{
field: 'price',
title: '合同金额(元)',
minWidth: 120,
formatter: 'formatAmount',
},
{
field: 'orderDate',
title: '下单时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'startTime',
title: '合同开始时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'endTime',
title: '合同结束时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'contactName',
title: '客户签约人',
minWidth: 130,
slots: {
default: 'contactName',
},
},
{
field: 'signUserName',
title: '公司签约人',
minWidth: 130,
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'totalReceivablePrice',
title: '已回款金额(元)',
minWidth: 140,
formatter: 'formatAmount',
},
{
field: 'noReceivablePrice',
title: '未回款金额(元)',
minWidth: 120,
formatter: 'formatAmount',
},
{
field: 'contactLastTime',
title: '最后跟进时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 120,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 100,
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'creatorName',
title: '创建人',
minWidth: 100,
},
{
field: 'auditStatus',
title: '合同状态',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_AUDIT_STATUS },
},
},
{
field: 'operation',
title: '操作',
minWidth: 130,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'no',
nameTitle: '合同编号',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'processDetail',
show: hasAccessByCodes(['crm:contract:update']),
},
],
},
},
];
}
/** 客户跟进列表的搜索表单 */
export function useCustomerFollowFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'followUpStatus',
label: '状态',
component: 'Select',
componentProps: {
allowClear: true,
options: FOLLOWUP_STATUS,
},
defaultValue: false,
},
];
}
/** 待进入公海客户列表的搜索表单 */
export function useCustomerPutPoolFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'sceneType',
label: '归属',
component: 'Select',
componentProps: {
allowClear: true,
options: SCENE_TYPES,
},
defaultValue: 1,
},
];
}
/** 今日需联系客户列表的搜索表单 */
export function useCustomerTodayContactFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'contactStatus',
label: '状态',
component: 'Select',
componentProps: {
allowClear: true,
options: CONTACT_STATUS,
},
defaultValue: 1,
},
{
fieldName: 'sceneType',
label: '归属',
component: 'Select',
componentProps: {
allowClear: true,
options: SCENE_TYPES,
},
defaultValue: 1,
},
];
}
/** 客户列表的字段 */
export function useCustomerColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '客户名称',
minWidth: 160,
slots: {
default: 'name',
},
},
{
field: 'source',
title: '客户来源',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_SOURCE },
},
},
{
field: 'mobile',
title: '手机',
minWidth: 120,
},
{
field: 'telephone',
title: '电话',
minWidth: 130,
},
{
field: 'email',
title: '邮箱',
minWidth: 180,
},
{
field: 'level',
title: '客户级别',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_LEVEL },
},
},
{
field: 'industryId',
title: '客户行业',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY },
},
},
{
field: 'contactNextTime',
title: '下次联系时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'lockStatus',
title: '锁定状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'dealStatus',
title: '成交状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'contactLastTime',
title: '最后跟进时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'contactLastContent',
title: '最后跟进记录',
minWidth: 200,
},
{
field: 'detailAddress',
title: '地址',
minWidth: 200,
},
{
field: 'poolDay',
title: '距离进入公海天数',
minWidth: 180,
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 100,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 100,
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'creatorName',
title: '创建人',
minWidth: 100,
},
];
}
/** 回款审核列表的搜索表单 */
export function useReceivableAuditFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'auditStatus',
label: '合同状态',
component: 'Select',
componentProps: {
allowClear: true,
options: AUDIT_STATUS,
},
defaultValue: 10,
},
];
}
/** 回款审核列表的字段 */
export function useReceivableAuditColumns<T = CrmReceivableApi.Receivable>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'no',
title: '回款编号',
minWidth: 180,
fixed: 'left',
slots: {
default: 'no',
},
},
{
field: 'customerName',
title: '客户名称',
minWidth: 120,
slots: {
default: 'customerName',
},
},
{
field: 'contractNo',
title: '合同编号',
minWidth: 180,
slots: {
default: 'contractNo',
},
},
{
field: 'returnTime',
title: '回款日期',
minWidth: 150,
formatter: 'formatDateTime',
},
{
field: 'price',
title: '回款金额(元)',
minWidth: 140,
formatter: 'formatAmount',
},
{
field: 'returnType',
title: '回款方式',
minWidth: 130,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE },
},
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'contract.totalPrice',
title: '合同金额(元)',
minWidth: 140,
formatter: 'formatAmount',
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 120,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 100,
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'creatorName',
title: '创建人',
minWidth: 120,
},
{
field: 'auditStatus',
title: '回款状态',
minWidth: 120,
fixed: 'right',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_AUDIT_STATUS },
},
},
{
field: 'operation',
title: '操作',
width: 140,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '角色',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'processDetail',
text: '查看审批',
show: hasAccessByCodes(['crm:receivable:update']),
},
],
},
},
];
}
/** 回款计划提醒列表的搜索表单 */
export function useReceivablePlanRemindFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'remindType',
label: '合同状态',
component: 'Select',
componentProps: {
allowClear: true,
options: RECEIVABLE_REMIND_TYPE,
},
defaultValue: 1,
},
];
}
/** 回款计划提醒列表的字段 */
export function useReceivablePlanRemindColumns<T = CrmReceivableApi.Receivable>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'customerName',
title: '客户名称',
minWidth: 160,
fixed: 'left',
slots: {
default: 'customerName',
},
},
{
field: 'contractNo',
title: '合同编号',
minWidth: 200,
},
{
field: 'period',
title: '期数',
minWidth: 160,
slots: {
default: 'period',
},
},
{
field: 'price',
title: '计划回款金额(元)',
minWidth: 120,
formatter: 'formatAmount',
},
{
field: 'returnTime',
title: '计划回款日期',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'remindDays',
title: '提前几天提醒',
minWidth: 150,
},
{
field: 'remindTime',
title: '提醒日期',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'returnType',
title: '回款方式',
minWidth: 120,
fixed: 'right',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE },
},
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 100,
},
{
field: 'receivable.price',
title: '实际回款金额(元)',
minWidth: 160,
formatter: 'formatAmount',
},
{
field: 'receivable.returnTime',
title: '实际回款日期',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'creatorName',
title: '创建人',
minWidth: 100,
},
{
field: 'operation',
title: '操作',
width: 140,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'customerName',
nameTitle: '客户名称',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'receivableForm',
text: '创建回款',
show: hasAccessByCodes(['crm:receivable:create']),
},
],
},
},
];
}

View File

@@ -0,0 +1,121 @@
<script lang="ts" setup>
import { computed, onActivated, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Badge, Card, List } from 'ant-design-vue';
import * as ClueApi from '#/api/crm/clue';
import * as ContractApi from '#/api/crm/contract';
import * as CustomerApi from '#/api/crm/customer';
import * as ReceivableApi from '#/api/crm/receivable';
import * as ReceivablePlanApi from '#/api/crm/receivable/plan';
import { DocAlert } from '#/components/doc-alert';
import { useLeftSides } from './data';
import ClueFollowList from './modules/ClueFollowList.vue';
import ContractAuditList from './modules/ContractAuditList.vue';
import ContractRemindList from './modules/ContractRemindList.vue';
import CustomerFollowList from './modules/CustomerFollowList.vue';
import CustomerPutPoolRemindList from './modules/CustomerPutPoolRemindList.vue';
import CustomerTodayContactList from './modules/CustomerTodayContactList.vue';
import ReceivableAuditList from './modules/ReceivableAuditList.vue';
import ReceivablePlanRemindList from './modules/ReceivablePlanRemindList.vue';
defineOptions({ name: 'CrmBacklog' });
const leftMenu = ref('customerTodayContact');
const clueFollowCount = ref(0);
const customerFollowCount = ref(0);
const customerPutPoolRemindCount = ref(0);
const customerTodayContactCount = ref(0);
const contractAuditCount = ref(0);
const contractRemindCount = ref(0);
const receivableAuditCount = ref(0);
const receivablePlanRemindCount = ref(0);
const leftSides = useLeftSides(
customerTodayContactCount,
clueFollowCount,
customerFollowCount,
customerPutPoolRemindCount,
contractAuditCount,
contractRemindCount,
receivableAuditCount,
receivablePlanRemindCount,
);
const currentComponent = computed(() => {
const components = {
customerTodayContact: CustomerTodayContactList,
clueFollow: ClueFollowList,
contractAudit: ContractAuditList,
receivableAudit: ReceivableAuditList,
contractRemind: ContractRemindList,
customerFollow: CustomerFollowList,
customerPutPoolRemind: CustomerPutPoolRemindList,
receivablePlanRemind: ReceivablePlanRemindList,
} as const;
return components[leftMenu.value as keyof typeof components];
});
/** 侧边点击 */
function sideClick(item: { menu: string }) {
leftMenu.value = item.menu;
}
/** 获取数量 */
async function getCount() {
customerTodayContactCount.value =
await CustomerApi.getTodayContactCustomerCount();
customerPutPoolRemindCount.value =
await CustomerApi.getPutPoolRemindCustomerCount();
customerFollowCount.value = await CustomerApi.getFollowCustomerCount();
clueFollowCount.value = await ClueApi.getFollowClueCount();
contractAuditCount.value = await ContractApi.getAuditContractCount();
contractRemindCount.value = await ContractApi.getRemindContractCount();
receivableAuditCount.value = await ReceivableApi.getAuditReceivableCount();
receivablePlanRemindCount.value =
await ReceivablePlanApi.getReceivablePlanRemindCount();
}
/** 激活时 */
onActivated(async () => {
getCount();
});
/** 初始化 */
onMounted(async () => {
getCount();
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【通用】跟进记录、待办事项"
url="https://doc.iocoder.cn/crm/follow-up/"
/>
</template>
<div class="flex h-full w-full">
<Card class="w-1/5">
<List item-layout="horizontal" :data-source="leftSides">
<template #renderItem="{ item }">
<List.Item>
<List.Item.Meta>
<template #title>
<a @click="sideClick(item)"> {{ item.name }} </a>
</template>
</List.Item.Meta>
<template #extra v-if="item.count.value && item.count.value > 0">
<Badge :count="item.count.value" />
</template>
</List.Item>
</template>
</List>
</Card>
<component class="ml-4 w-4/5" :is="currentComponent" />
</div>
</Page>
</template>

View File

@@ -0,0 +1,58 @@
<!-- 分配给我的线索 -->
<script lang="ts" setup>
import type { CrmClueApi } from '#/api/crm/clue';
import { useRouter } from 'vue-router';
import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCluePage } from '#/api/crm/clue';
import { useClueFollowColumns, useClueFollowFormSchema } from '../data';
const { push } = useRouter();
/** 打开线索详情 */
function onDetail(row: CrmClueApi.Clue) {
push({ name: 'CrmClueDetail', params: { id: row.id } });
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useClueFollowFormSchema(),
},
gridOptions: {
columns: useClueFollowColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCluePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
transformStatus: false,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
},
});
</script>
<template>
<Grid table-title="分配给我的线索">
<template #name="{ row }">
<Button type="link" @click="onDetail(row)">{{ row.name }}</Button>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,111 @@
<!-- 待审核合同 -->
<script lang="ts" setup>
import type { OnActionClickParams } from '#/adapter/vxe-table';
import type { CrmContractApi } from '#/api/crm/contract';
import { useRouter } from 'vue-router';
import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getContractPage } from '#/api/crm/contract';
import { useContractAuditFormSchema, useContractColumns } from '../data';
const { push } = useRouter();
/** 查看审批 */
function openProcessDetail(row: CrmContractApi.Contract) {
push({
name: 'BpmProcessInstanceDetail',
query: { id: row.processInstanceId },
});
}
/** 打开合同详情 */
function openContractDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmContractDetail', params: { id: row.id } });
}
/** 打开客户详情 */
function openCustomerDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
/** 打开联系人详情 */
function openContactDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmContactDetail', params: { id: row.id } });
}
/** 打开商机详情 */
function openBusinessDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmBusinessDetail', params: { id: row.id } });
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<CrmContractApi.Contract>) {
switch (code) {
case 'processDetail': {
openProcessDetail(row);
break;
}
}
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useContractAuditFormSchema(),
},
gridOptions: {
columns: useContractColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getContractPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
sceneType: 1, // 我负责的
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
},
});
</script>
<template>
<Grid table-title="待审核合同">
<template #name="{ row }">
<Button type="link" @click="openContractDetail(row)">
{{ row.name }}
</Button>
</template>
<template #customerName="{ row }">
<Button type="link" @click="openCustomerDetail(row)">
{{ row.customerName }}
</Button>
</template>
<template #businessName="{ row }">
<Button type="link" @click="openBusinessDetail(row)">
{{ row.businessName }}
</Button>
</template>
<template #contactName="{ row }">
<Button type="link" @click="openContactDetail(row)">
{{ row.contactName }}
</Button>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,111 @@
<!-- 即将到期的合同 -->
<script lang="ts" setup>
import type { OnActionClickParams } from '#/adapter/vxe-table';
import type { CrmContractApi } from '#/api/crm/contract';
import { useRouter } from 'vue-router';
import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getContractPage } from '#/api/crm/contract';
import { useContractColumns, useContractRemindFormSchema } from '../data';
const { push } = useRouter();
/** 查看审批 */
function openProcessDetail(row: CrmContractApi.Contract) {
push({
name: 'BpmProcessInstanceDetail',
query: { id: row.processInstanceId },
});
}
/** 打开合同详情 */
function openContractDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmContractDetail', params: { id: row.id } });
}
/** 打开客户详情 */
function openCustomerDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
/** 打开联系人详情 */
function openContactDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmContactDetail', params: { id: row.id } });
}
/** 打开商机详情 */
function openBusinessDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmBusinessDetail', params: { id: row.id } });
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<CrmContractApi.Contract>) {
switch (code) {
case 'processDetail': {
openProcessDetail(row);
break;
}
}
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useContractRemindFormSchema(),
},
gridOptions: {
columns: useContractColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getContractPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
sceneType: 1, // 自己负责的
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
},
});
</script>
<template>
<Grid table-title="即将到期的合同">
<template #name="{ row }">
<Button type="link" @click="openContractDetail(row)">
{{ row.name }}
</Button>
</template>
<template #customerName="{ row }">
<Button type="link" @click="openCustomerDetail(row)">
{{ row.customerName }}
</Button>
</template>
<template #businessName="{ row }">
<Button type="link" @click="openBusinessDetail(row)">
{{ row.businessName }}
</Button>
</template>
<template #contactName="{ row }">
<Button type="link" @click="openContactDetail(row)">
{{ row.contactName }}
</Button>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,58 @@
<!-- 分配给我的客户 -->
<script lang="ts" setup>
import type { CrmCustomerApi } from '#/api/crm/customer';
import { useRouter } from 'vue-router';
import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCustomerPage } from '#/api/crm/customer';
import { useCustomerColumns, useCustomerFollowFormSchema } from '../data';
const { push } = useRouter();
/** 打开客户详情 */
function onDetail(row: CrmCustomerApi.Customer) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useCustomerFollowFormSchema(),
},
gridOptions: {
columns: useCustomerColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCustomerPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
sceneType: 1,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
},
});
</script>
<template>
<Grid table-title="分配给我的客户">
<template #name="{ row }">
<Button type="link" @click="onDetail(row)">{{ row.name }}</Button>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,58 @@
<!-- 待进入公海的客户 -->
<script lang="ts" setup>
import type { CrmCustomerApi } from '#/api/crm/customer';
import { useRouter } from 'vue-router';
import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCustomerPage } from '#/api/crm/customer';
import { useCustomerColumns, useCustomerPutPoolFormSchema } from '../data';
const { push } = useRouter();
/** 打开客户详情 */
function onDetail(row: CrmCustomerApi.Customer) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useCustomerPutPoolFormSchema(),
},
gridOptions: {
columns: useCustomerColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCustomerPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
pool: true, // 固定 公海参数为 true
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
},
});
</script>
<template>
<Grid table-title="待进入公海的客户">
<template #name="{ row }">
<Button type="link" @click="onDetail(row)">{{ row.name }}</Button>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,58 @@
<!-- 今日需联系客户 -->
<script lang="ts" setup>
import type { CrmCustomerApi } from '#/api/crm/customer';
import { useRouter } from 'vue-router';
import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCustomerPage } from '#/api/crm/customer';
import { useCustomerColumns, useCustomerTodayContactFormSchema } from '../data';
const { push } = useRouter();
/** 打开客户详情 */
function onDetail(row: CrmCustomerApi.Customer) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useCustomerTodayContactFormSchema(),
},
gridOptions: {
columns: useCustomerColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCustomerPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
pool: null, // 是否公海数据
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
},
});
</script>
<template>
<Grid table-title="今日需联系客户">
<template #name="{ row }">
<Button type="link" @click="onDetail(row)">{{ row.name }}</Button>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,104 @@
<!-- 待审核回款 -->
<script lang="ts" setup>
import type { OnActionClickParams } from '#/adapter/vxe-table';
import type { CrmReceivableApi } from '#/api/crm/receivable';
import { useRouter } from 'vue-router';
import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getReceivablePage } from '#/api/crm/receivable';
import {
useReceivableAuditColumns,
useReceivableAuditFormSchema,
} from '../data';
const { push } = useRouter();
/** 查看审批 */
function openProcessDetail(row: CrmReceivableApi.Receivable) {
push({
name: 'BpmProcessInstanceDetail',
query: { id: row.processInstanceId },
});
}
/** 打开回款详情 */
function openDetail(row: CrmReceivableApi.Receivable) {
push({ name: 'CrmReceivableDetail', params: { id: row.id } });
}
/** 打开客户详情 */
function openCustomerDetail(row: CrmReceivableApi.Receivable) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** 打开合同详情 */
function openContractDetail(row: CrmReceivableApi.Receivable) {
push({ name: 'CrmContractDetail', params: { id: row.contractId } });
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<CrmReceivableApi.Receivable>) {
switch (code) {
case 'processDetail': {
openProcessDetail(row);
break;
}
}
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useReceivableAuditFormSchema(),
},
gridOptions: {
columns: useReceivableAuditColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getReceivablePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
},
});
</script>
<template>
<Grid table-title="待审核回款">
<template #no="{ row }">
<Button type="link" @click="openDetail(row)">
{{ row.no }}
</Button>
</template>
<template #customerName="{ row }">
<Button type="link" @click="openCustomerDetail(row)">
{{ row.customerName }}
</Button>
</template>
<template #contractNo="{ row }">
<Button type="link" @click="openContractDetail(row)">
{{ row.contractNo }}
</Button>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,90 @@
<!-- 待回款提醒 -->
<script lang="ts" setup>
import type { OnActionClickParams } from '#/adapter/vxe-table';
import type { CrmReceivableApi } from '#/api/crm/receivable';
import { useRouter } from 'vue-router';
import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getReceivablePage } from '#/api/crm/receivable';
import {
useReceivablePlanRemindColumns,
useReceivablePlanRemindFormSchema,
} from '../data';
const { push } = useRouter();
/** 打开回款详情 */
function openDetail(row: CrmReceivableApi.Receivable) {
push({ name: 'CrmReceivableDetail', params: { id: row.id } });
}
/** 打开客户详情 */
function openCustomerDetail(row: CrmReceivableApi.Receivable) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** 创建回款 */
function openReceivableForm(row: CrmReceivableApi.Receivable) {
// Todo: 打开创建回款
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<CrmReceivableApi.Receivable>) {
switch (code) {
case 'receivableForm': {
openReceivableForm(row);
break;
}
}
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useReceivablePlanRemindFormSchema(),
},
gridOptions: {
columns: useReceivablePlanRemindColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getReceivablePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
},
});
</script>
<template>
<Grid table-title="待回款提醒">
<template #customerName="{ row }">
<Button type="link" @click="openCustomerDetail(row)">
{{ row.customerName }}
</Button>
</template>
<template #period="{ row }">
<Button type="link" @click="openDetail(row)">{{ row.period }}</Button>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,211 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmBusinessApi } from '#/api/crm/business';
import { useAccess } from '@vben/access';
import { getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '商机名称',
component: 'Input',
rules: 'required',
},
{
fieldName: 'customerId',
label: '客户',
component: 'Input',
rules: 'required',
},
{
fieldName: 'totalPrice',
label: '商机金额',
component: 'InputNumber',
componentProps: {
min: 0,
controlsPosition: 'right',
placeholder: '请输入商机金额',
},
rules: 'required',
},
{
fieldName: 'dealTime',
label: '预计成交日期',
component: 'DatePicker',
rules: 'required',
componentProps: {
showTime: false,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
},
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
},
{
fieldName: 'contactNextTime',
label: '下次联系时间',
component: 'DatePicker',
componentProps: {
showTime: false,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '商机名称',
component: 'Input',
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = CrmBusinessApi.Business>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '商机名称',
minWidth: 160,
fixed: 'left',
slots: {
default: 'name',
},
},
{
field: 'customerName',
title: '客户名称',
minWidth: 120,
fixed: 'left',
slots: {
default: 'customerName',
},
},
{
field: 'totalPrice',
title: '商机金额(元)',
minWidth: 140,
formatter: 'formatAmount',
},
{
field: 'dealTime',
title: '预计成交日期',
minWidth: 180,
formatter: 'formatDate',
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'contactNextTime',
title: '下次联系时间',
minWidth: 180,
formatter: 'formatDate',
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 100,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 100,
},
{
field: 'contactLastTime',
title: '最后跟进时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'creatorName',
title: '创建人',
minWidth: 100,
},
{
field: 'statusTypeName',
title: '商机状态组',
minWidth: 140,
fixed: 'right',
},
{
field: 'statusName',
title: '商机阶段',
minWidth: 120,
fixed: 'right',
},
{
field: 'operation',
title: '操作',
width: 130,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '商机',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['crm:business:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['crm:business:delete']),
},
],
},
},
];
}

View File

@@ -0,0 +1,175 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { CrmBusinessApi } from '#/api/crm/business';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteBusiness,
exportBusiness,
getBusinessPage,
} from '#/api/crm/business';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const { push } = useRouter();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportBusiness(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '商机.xls', source: data });
}
/** 创建商机 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 编辑商机 */
function onEdit(row: CrmBusinessApi.Business) {
formModalApi.setData(row).open();
}
/** 删除商机 */
async function onDelete(row: CrmBusinessApi.Business) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteBusiness(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} catch {
hideLoading();
}
}
/** 查看商机详情 */
function onDetail(row: CrmBusinessApi.Business) {
push({ name: 'CrmBusinessDetail', params: { id: row.id } });
}
/** 查看客户详情 */
function onCustomerDetail(row: CrmBusinessApi.Business) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<CrmBusinessApi.Business>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getBusinessPage({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<CrmBusinessApi.Business>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【商机】商机管理、商机状态"
url="https://doc.iocoder.cn/crm/business/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid table-title="商机列表">
<template #toolbar-tools>
<Button
type="primary"
@click="onCreate"
v-access:code="['crm:business:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['商机']) }}
</Button>
<Button
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['crm:business:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</Button>
</template>
<template #name="{ row }">
<Button type="link" @click="onDetail(row)">
{{ row.name }}
</Button>
</template>
<template #customerName="{ row }">
<Button type="link" @click="onCustomerDetail(row)">
{{ row.customerName }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup></script>
<template>
<div>
<p>待完成</p>
</div>
</template>

View File

@@ -0,0 +1,86 @@
<script lang="ts" setup>
import type { CrmBusinessApi } from '#/api/crm/business';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createBusiness,
getBusiness,
updateBusiness,
} from '#/api/crm/business';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmBusinessApi.Business>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['商机'])
: $t('ui.actionTitle.create', ['商机']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as CrmBusinessApi.Business;
try {
await (formData.value?.id ? updateBusiness(data) : createBusiness(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<CrmBusinessApi.Business>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getBusiness(data.id as number);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-[40%]">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,134 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmBusinessStatusApi } from '#/api/crm/business/status';
import { useAccess } from '@vben/access';
import { z } from '#/adapter/form';
import {
CommonStatusEnum,
DICT_TYPE,
getDictOptions,
getRangePickerDefaultProps,
} from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '状态组名',
component: 'Input',
rules: 'required',
},
{
fieldName: 'deptIds',
label: '应用部门',
component: 'TreeSelect',
componentProps: {
multiple: true,
treeCheckable: true,
showCheckedStrategy: 'SHOW_PARENT',
placeholder: '请选择应用部门',
},
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '状态组名',
component: 'Input',
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = CrmBusinessStatusApi.BusinessStatus>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '状态组名',
minWidth: 200,
},
{
field: 'deptNames',
title: '应用部门',
minWidth: 200,
formatter: ({ cellValue }) => {
return cellValue?.length > 0 ? cellValue.join(' ') : '全公司';
},
},
{
field: 'creator',
title: '创建人',
minWidth: 100,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
width: 160,
fixed: 'right',
align: 'center',
cellRender: {
name: 'TableAction',
props: {
actions: [
{
label: '编辑',
code: 'edit',
show: hasAccessByCodes(['crm:business-status:update']),
},
{
label: '删除',
code: 'delete',
show: hasAccessByCodes(['crm:business-status:delete']),
},
],
onActionClick,
},
},
},
];
}

View File

@@ -0,0 +1,134 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { CrmBusinessStatusApi } from '#/api/crm/business/status';
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 {
deleteBusinessStatus,
getBusinessStatusPage,
} from '#/api/crm/business/status';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建商机状态 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 删除商机状态 */
async function onDelete(row: CrmBusinessStatusApi.BusinessStatus) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteBusinessStatus(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} catch {
hideLoading();
}
}
/** 编辑商机状态 */
function onEdit(row: CrmBusinessStatusApi.BusinessStatus) {
formModalApi.setData(row).open();
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<CrmBusinessStatusApi.BusinessStatus>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getBusinessStatusPage({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<CrmBusinessStatusApi.BusinessStatus>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【商机】商机管理、商机状态"
url="https://doc.iocoder.cn/crm/business/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid table-title="商机状态列表">
<template #toolbar-tools>
<Button
type="primary"
@click="onCreate"
v-access:code="['crm:business-status:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['商机状态']) }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,91 @@
<script lang="ts" setup>
import type { CrmBusinessStatusApi } from '#/api/crm/business/status';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createBusinessStatus,
getBusinessStatus,
updateBusinessStatus,
} from '#/api/crm/business/status';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmBusinessStatusApi.BusinessStatusType>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['商机状态'])
: $t('ui.actionTitle.create', ['商机状态']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data =
(await formApi.getValues()) as CrmBusinessStatusApi.BusinessStatusType;
try {
await (formData.value?.id
? updateBusinessStatus(data)
: createBusinessStatus(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<CrmBusinessStatusApi.BusinessStatusType>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getBusinessStatus(data.id as number);
// 设置到 values
if (formData.value) {
await formApi.setValues(formData.value);
}
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-1/2">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,429 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmClueApi } from '#/api/crm/clue';
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { useAccess } from '@vben/access';
import { formatDateTime } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '线索名称',
component: 'Input',
rules: 'required',
},
{
fieldName: 'source',
label: '客户来源',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.CRM_CUSTOMER_SOURCE),
},
rules: 'required',
},
{
fieldName: 'mobile',
label: '手机',
component: 'Input',
},
{
fieldName: 'ownerUserId',
label: '负责人',
component: 'Select',
componentProps: {
api: 'getSimpleUserList',
},
rules: 'required',
},
{
fieldName: 'telephone',
label: '电话',
component: 'Input',
},
{
fieldName: 'email',
label: '邮箱',
component: 'Input',
},
{
fieldName: 'wechat',
label: '微信',
component: 'Input',
},
{
fieldName: 'qq',
label: 'QQ',
component: 'Input',
},
{
fieldName: 'industryId',
label: '客户行业',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.CRM_CUSTOMER_INDUSTRY),
},
},
{
fieldName: 'level',
label: '客户级别',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.CRM_CUSTOMER_LEVEL),
},
},
{
fieldName: 'areaId',
label: '地址',
component: 'Cascader',
componentProps: {
api: 'getAreaTree',
},
},
{
fieldName: 'detailAddress',
label: '详细地址',
component: 'Input',
},
{
fieldName: 'contactNextTime',
label: '下次联系时间',
component: 'DatePicker',
componentProps: {
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
},
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '线索名称',
component: 'Input',
},
{
fieldName: 'transformStatus',
label: '转化状态',
component: 'Select',
componentProps: {
options: [
{ label: '未转化', value: false },
{ label: '已转化', value: true },
],
},
},
{
fieldName: 'mobile',
label: '手机号',
component: 'Input',
},
{
fieldName: 'telephone',
label: '电话',
component: 'Input',
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = CrmClueApi.Clue>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '线索名称',
minWidth: 160,
fixed: 'left',
slots: {
default: 'name',
},
},
{
field: 'source',
title: '线索来源',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_SOURCE },
},
},
{
field: 'mobile',
title: '手机',
minWidth: 120,
},
{
field: 'telephone',
title: '电话',
minWidth: 130,
},
{
field: 'email',
title: '邮箱',
minWidth: 180,
},
{
field: 'detailAddress',
title: '地址',
minWidth: 180,
},
{
field: 'industryId',
title: '客户行业',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY },
},
},
{
field: 'level',
title: '客户级别',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_LEVEL },
},
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 100,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 100,
},
{
field: 'contactNextTime',
title: '下次联系时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'contactLastTime',
title: '最后跟进时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'creatorName',
title: '创建人',
minWidth: 100,
},
{
field: 'operation',
title: '操作',
width: 130,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '线索',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['crm:clue:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['crm:clue:delete']),
},
],
},
},
];
}
/** 详情头部的配置 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'source',
label: '线索来源',
content: (data) =>
h(DictTag, {
type: DICT_TYPE.CRM_CUSTOMER_SOURCE,
value: data?.source,
}),
},
{
field: 'mobile',
label: '手机',
},
{
field: 'ownerUserName',
label: '负责人',
},
{
field: 'createTime',
label: '创建时间',
content: (data) => formatDateTime(data?.createTime) as string,
},
];
}
/** 详情基本信息的配置 */
export function useDetailBaseSchema(): DescriptionItemSchema[] {
return [
{
field: 'name',
label: '线索名称',
},
{
field: 'source',
label: '客户来源',
content: (data) =>
h(DictTag, {
type: DICT_TYPE.CRM_CUSTOMER_SOURCE,
value: data?.source,
}),
},
{
field: 'mobile',
label: '手机',
},
{
field: 'ownerUserName',
label: '负责人',
},
{
field: 'telephone',
label: '电话',
},
{
field: 'email',
label: '邮箱',
},
{
field: 'wechat',
label: '微信',
},
{
field: 'qq',
label: 'QQ',
},
{
field: 'industryId',
label: '客户行业',
content: (data) =>
h(DictTag, {
type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY,
value: data?.industryId,
}),
},
{
field: 'level',
label: '客户级别',
content: (data) =>
h(DictTag, {
type: DICT_TYPE.CRM_CUSTOMER_LEVEL,
value: data?.level,
}),
},
{
field: 'areaId',
label: '地址',
},
{
field: 'detailAddress',
label: '详细地址',
},
{
field: 'contactNextTime',
label: '下次联系时间',
content: (data) => formatDateTime(data?.contactNextTime) as string,
},
{
field: 'remark',
label: '备注',
},
];
}
/** 详情系统信息的配置 */
export function useDetailSystemSchema(): DescriptionItemSchema[] {
return [
{
field: 'ownerUserName',
label: '负责人',
},
{
field: 'contactLastContent',
label: '最后跟进记录',
},
{
field: 'contactLastContent',
label: '最后跟进时间',
content: (data) => formatDateTime(data?.contactLastContent) as string,
},
{
field: 'creatorName',
label: '创建人',
},
{
field: 'createTime',
label: '创建时间',
content: (data) => formatDateTime(data?.createTime) as string,
},
{
field: 'updateTime',
label: '更新时间',
content: (data) => formatDateTime(data?.updateTime) as string,
},
];
}

View File

@@ -0,0 +1,158 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { CrmClueApi } from '#/api/crm/clue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteClue, exportClue, getCluePage } from '#/api/crm/clue';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const { push } = useRouter();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportClue(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '线索.xls', source: data });
}
/** 创建线索 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 编辑线索 */
function onEdit(row: CrmClueApi.Clue) {
formModalApi.setData(row).open();
}
/** 删除线索 */
async function onDelete(row: CrmClueApi.Clue) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteClue(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} catch {
hideLoading();
}
}
/** 查看线索详情 */
function onDetail(row: CrmClueApi.Clue) {
push({ name: 'CrmClueDetail', params: { id: row.id } });
}
/** 表格操作按钮的回调函数 */
function onActionClick({ code, row }: OnActionClickParams<CrmClueApi.Clue>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCluePage({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<CrmClueApi.Clue>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【线索】线索管理"
url="https://doc.iocoder.cn/crm/clue/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid table-title="线索列表">
<template #toolbar-tools>
<Button
type="primary"
@click="onCreate"
v-access:code="['crm:clue:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['线索']) }}
</Button>
<Button
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['crm:clue:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</Button>
</template>
<template #name="{ row }">
<Button type="link" @click="onDetail(row)">
{{ row.name }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,45 @@
<script lang="ts" setup>
import type { CrmClueApi } from '#/api/crm/clue';
import { Divider } from 'ant-design-vue';
import { useDescription } from '#/components/description';
import { useDetailBaseSchema, useDetailSystemSchema } from '../data';
defineOptions({ name: 'CrmClueDetailsInfo' });
const { clue } = defineProps<{
clue: CrmClueApi.Clue; // 线索信息
}>();
const [BaseDescription] = useDescription({
componentProps: {
title: '基本信息',
bordered: false,
column: 4,
class: 'mx-4',
},
schema: useDetailBaseSchema(),
data: clue,
});
const [SystemDescription] = useDescription({
componentProps: {
title: '系统信息',
bordered: false,
column: 3,
class: 'mx-4',
},
schema: useDetailSystemSchema(),
data: clue,
});
</script>
<template>
<div class="p-4">
<BaseDescription />
<Divider />
<SystemDescription />
</div>
</template>

View File

@@ -0,0 +1,155 @@
<script setup lang="ts">
import type { CrmClueApi } from '#/api/crm/clue';
import { defineAsyncComponent, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { ArrowLeft } from '@vben/icons';
import { Button, Card, Modal, Tabs } from 'ant-design-vue';
import { getClue, transformClue } from '#/api/crm/clue';
import { useDescription } from '#/components/description';
import { useDetailSchema } from '../data';
import ClueForm from './form.vue';
import TransferForm from './transfer.vue';
const ClueDetailsInfo = defineAsyncComponent(() => import('./detail-info.vue'));
const loading = ref(false);
const route = useRoute();
const router = useRouter();
const clueId = ref(0);
const clue = ref<CrmClueApi.Clue>({} as CrmClueApi.Clue);
const permissionListRef = ref(); // 团队成员列表 Ref
const [Description] = useDescription({
componentProps: {
bordered: false,
column: 4,
class: 'mx-4',
},
schema: useDetailSchema(),
});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: ClueForm,
destroyOnClose: true,
});
const [TransferModal, transferModalApi] = useVbenModal({
connectedComponent: TransferForm,
destroyOnClose: true,
});
/** 加载线索详情 */
async function loadClueDetail() {
loading.value = true;
clueId.value = Number(route.params.id);
const data = await getClue(clueId.value);
clue.value = data;
loading.value = false;
}
/** 返回列表页 */
function onBack() {
router.push('/crm/clue');
}
/** 编辑线索 */
function onEdit() {
formModalApi.setData({ id: clueId }).open();
}
/** 转移线索 */
function onTransfer() {
transferModalApi.setData({ id: clueId }).open();
}
/** 转化为客户 */
async function onTransform() {
try {
await Modal.confirm({
title: '提示',
content: '确定将该线索转化为客户吗?',
});
await transformClue(clueId.value);
Modal.success({
title: '成功',
content: '转化客户成功',
});
await loadClueDetail();
} catch {
// 用户取消操作
}
}
// 加载数据
onMounted(async () => {
await loadClueDetail();
});
</script>
<template>
<Page auto-content-height :title="clue?.name" :loading="loading">
<template #extra>
<div class="flex items-center gap-2">
<Button @click="onBack">
<ArrowLeft class="size-5" />
返回
</Button>
<Button
v-if="permissionListRef?.validateWrite"
type="primary"
@click="onEdit"
v-access:code="['crm:clue:update']"
>
{{ $t('ui.actionTitle.edit') }}
</Button>
<Button
v-if="permissionListRef?.validateOwnerUser"
type="primary"
@click="onTransfer"
v-access:code="['crm:clue:update']"
>
转移
</Button>
<Button
v-if="permissionListRef?.validateOwnerUser && !clue?.transformStatus"
type="primary"
@click="onTransform"
v-access:code="['crm:clue:update']"
>
转化为客户
</Button>
</div>
</template>
<Card>
<Description :data="clue" />
</Card>
<Card class="mt-4">
<Tabs>
<Tabs.TabPane tab="线索跟进" key="1">
<div>线索跟进</div>
</Tabs.TabPane>
<Tabs.TabPane tab="基本信息" key="2">
<ClueDetailsInfo :clue="clue" />
</Tabs.TabPane>
<Tabs.TabPane tab="团队成员" key="3">
<div>团队成员</div>
</Tabs.TabPane>
<Tabs.TabPane tab="操作日志" key="4">
<div>操作日志</div>
</Tabs.TabPane>
</Tabs>
</Card>
<FormModal @success="loadClueDetail" />
<TransferModal @success="loadClueDetail" />
</Page>
</template>

View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
import type { CrmClueApi } from '#/api/crm/clue';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createClue, getClue, updateClue } from '#/api/crm/clue';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmClueApi.Clue>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['线索'])
: $t('ui.actionTitle.create', ['线索']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as CrmClueApi.Clue;
try {
await (formData.value?.id ? updateClue(data) : createClue(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<CrmClueApi.Clue>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getClue(data.id as number);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-[40%]">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,78 @@
<script lang="ts" setup>
import type { CrmPermissionApi } from '#/api/crm/permission';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { transferClue } from '#/api/crm/clue';
import { $t } from '#/locales';
const emit = defineEmits(['success']);
const formData = ref<{ id: number }>();
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: [
{
fieldName: 'ownerUserId',
label: '负责人',
component: 'Select',
componentProps: {
api: 'getSimpleUserList',
},
rules: 'required',
},
],
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as CrmPermissionApi.TransferReq;
try {
await transferClue(data);
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<{ id: number }>();
if (!data || !data.id) {
return;
}
formData.value = data;
},
});
</script>
<template>
<Modal :title="$t('ui.actionTitle.transfer')" class="w-[40%]">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,38 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { DocAlert } from '#/components/doc-alert';
</script>
<template>
<Page>
<DocAlert
title="【客户】客户管理、公海客户"
url="https://doc.iocoder.cn/crm/customer/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/contact/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/contact/index
代码pull request 贡献给我们
</Button>
</Page>
</template>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup></script>
<template>
<div>
<p>待完成</p>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { DocAlert } from '#/components/doc-alert';
</script>
<template>
<Page>
<DocAlert
title="【合同】合同管理、合同提醒"
url="https://doc.iocoder.cn/crm/contract/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/contract/config/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/contract/config/index
代码pull request 贡献给我们
</Button>
</Page>
</template>

View File

@@ -0,0 +1,38 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { DocAlert } from '#/components/doc-alert';
</script>
<template>
<Page>
<DocAlert
title="【合同】合同管理、合同提醒"
url="https://doc.iocoder.cn/crm/contract/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/contract/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/contract/index
代码pull request 贡献给我们
</Button>
</Page>
</template>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup></script>
<template>
<div>
<p>待完成</p>
</div>
</template>

View File

@@ -0,0 +1,379 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmCustomerApi } from '#/api/crm/customer';
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { useAccess } from '@vben/access';
import { formatDateTime } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '客户名称',
component: 'Input',
rules: 'required',
},
{
fieldName: 'source',
label: '客户来源',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.CRM_CUSTOMER_SOURCE),
},
rules: 'required',
},
{
fieldName: 'mobile',
label: '手机',
component: 'Input',
},
{
fieldName: 'ownerUserId',
label: '负责人',
component: 'Select',
componentProps: {
api: 'getSimpleUserList',
},
rules: 'required',
},
{
fieldName: 'telephone',
label: '电话',
component: 'Input',
},
{
fieldName: 'email',
label: '邮箱',
component: 'Input',
},
{
fieldName: 'wechat',
label: '微信',
component: 'Input',
},
{
fieldName: 'qq',
label: 'QQ',
component: 'Input',
},
{
fieldName: 'industryId',
label: '客户行业',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.CRM_CUSTOMER_INDUSTRY),
},
},
{
fieldName: 'level',
label: '客户级别',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.CRM_CUSTOMER_LEVEL),
},
},
{
fieldName: 'areaId',
label: '地址',
component: 'Cascader',
componentProps: {
api: 'getAreaTree',
},
},
{
fieldName: 'detailAddress',
label: '详细地址',
component: 'Input',
},
{
fieldName: 'contactNextTime',
label: '下次联系时间',
component: 'DatePicker',
componentProps: {
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
},
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '客户名称',
component: 'Input',
},
{
fieldName: 'mobile',
label: '手机号',
component: 'Input',
},
{
fieldName: 'telephone',
label: '电话',
component: 'Input',
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = CrmCustomerApi.Customer>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '客户名称',
minWidth: 160,
fixed: 'left',
slots: {
default: 'name',
},
},
{
field: 'source',
title: '客户来源',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_SOURCE },
},
},
{
field: 'mobile',
title: '手机',
minWidth: 120,
},
{
field: 'telephone',
title: '电话',
minWidth: 130,
},
{
field: 'email',
title: '邮箱',
minWidth: 180,
},
{
field: 'detailAddress',
title: '地址',
minWidth: 180,
},
{
field: 'industryId',
title: '客户行业',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY },
},
},
{
field: 'level',
title: '客户级别',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_LEVEL },
},
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 100,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 100,
},
{
field: 'contactNextTime',
title: '下次联系时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'contactLastTime',
title: '最后跟进时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
width: 130,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '线索',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['crm:clue:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['crm:clue:delete']),
},
],
},
},
];
}
/** 详情页的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [...useDetailBaseSchema(), ...useDetailSystemSchema()];
}
/** 详情页的基础字段 */
export function useDetailBaseSchema(): DescriptionItemSchema[] {
return [
{
field: 'name',
label: '客户名称',
},
{
field: 'source',
label: '客户来源',
content: (data) =>
h(DictTag, {
type: DICT_TYPE.CRM_CUSTOMER_SOURCE,
value: data?.source,
}),
},
{
field: 'mobile',
label: '手机',
},
{
field: 'telephone',
label: '电话',
},
{
field: 'email',
label: '邮箱',
},
{
field: 'wechat',
label: '微信',
},
{
field: 'qq',
label: 'QQ',
},
{
field: 'industryId',
label: '客户行业',
content: (data) =>
h(DictTag, {
type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY,
value: data?.industryId,
}),
},
{
field: 'level',
label: '客户级别',
content: (data) =>
h(DictTag, { type: DICT_TYPE.CRM_CUSTOMER_LEVEL, value: data?.level }),
},
{
field: 'areaName',
label: '地址',
},
{
field: 'detailAddress',
label: '详细地址',
},
{
field: 'contactNextTime',
label: '下次联系时间',
content: (data) => formatDateTime(data?.contactNextTime) as string,
},
{
field: 'remark',
label: '备注',
},
];
}
/** 详情页的系统字段 */
export function useDetailSystemSchema(): DescriptionItemSchema[] {
return [
{
field: 'ownerUserName',
label: '负责人',
},
{
field: 'ownerUserDeptName',
label: '所属部门',
},
{
field: 'contactLastTime',
label: '最后跟进时间',
content: (data) => formatDateTime(data?.contactLastTime) as string,
},
{
field: 'createTime',
label: '创建时间',
content: (data) => formatDateTime(data?.createTime) as string,
},
{
field: 'updateTime',
label: '更新时间',
content: (data) => formatDateTime(data?.updateTime) as string,
},
];
}

View File

@@ -0,0 +1,181 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { CrmCustomerApi } from '#/api/crm/customer';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tabs } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteCustomer,
exportCustomer,
getCustomerPage,
} from '#/api/crm/customer';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const { push } = useRouter();
const sceneType = ref('1');
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportCustomer(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '客户.xls', source: data });
}
/** 创建客户 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 编辑客户 */
function onEdit(row: CrmCustomerApi.Customer) {
formModalApi.setData(row).open();
}
/** 删除客户 */
async function onDelete(row: CrmCustomerApi.Customer) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteCustomer(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} catch {
hideLoading();
}
}
/** 查看客户详情 */
function onDetail(row: CrmCustomerApi.Customer) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<CrmCustomerApi.Customer>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCustomerPage({
page: page.currentPage,
pageSize: page.pageSize,
sceneType: sceneType.value,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<CrmCustomerApi.Customer>,
});
function onChangeSceneType(key: number | string) {
sceneType.value = key.toString();
gridApi.query();
}
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【客户】客户管理"
url="https://doc.iocoder.cn/crm/customer/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid>
<template #toolbar-actions>
<Tabs class="border-none" @change="onChangeSceneType">
<Tabs.TabPane tab="我负责的" key="1" />
<Tabs.TabPane tab="我参与的" key="2" />
<Tabs.TabPane tab="下属负责的" key="3" />
</Tabs>
</template>
<template #toolbar-tools>
<Button
type="primary"
@click="onCreate"
v-access:code="['crm:customer:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['客户']) }}
</Button>
<Button
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['crm:customer:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</Button>
</template>
<template #name="{ row }">
<Button type="link" @click="onDetail(row)">
{{ row.name }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,38 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { DocAlert } from '#/components/doc-alert';
</script>
<template>
<Page>
<DocAlert
title="【客户】客户管理、公海客户"
url="https://doc.iocoder.cn/crm/customer/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/customer/limitConfig/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/customer/limitConfig/index
代码pull request 贡献给我们
</Button>
</Page>
</template>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup></script>
<template>
<div>
<p>待完成</p>
</div>
</template>

View File

@@ -0,0 +1,85 @@
<script lang="ts" setup>
import type { CrmCustomerApi } from '#/api/crm/customer';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import {
createCustomer,
getCustomer,
updateCustomer,
} from '#/api/crm/customer';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmCustomerApi.Customer>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['客户'])
: $t('ui.actionTitle.create', ['客户']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as CrmCustomerApi.Customer;
try {
await (formData.value?.id ? updateCustomer(data) : createCustomer(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<CrmCustomerApi.Customer>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getCustomer(data.id as number);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-[40%]">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,38 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { DocAlert } from '#/components/doc-alert';
</script>
<template>
<Page>
<DocAlert
title="【客户】客户管理、公海客户"
url="https://doc.iocoder.cn/crm/customer/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/customer/pool/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/customer/pool/index
代码pull request 贡献给我们
</Button>
</Page>
</template>

View File

@@ -0,0 +1,38 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { DocAlert } from '#/components/doc-alert';
</script>
<template>
<Page>
<DocAlert
title="【客户】客户管理、公海客户"
url="https://doc.iocoder.cn/crm/customer/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/customer/poolConfig/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/customer/poolConfig/index
代码pull request 贡献给我们
</Button>
</Page>
</template>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup></script>
<template>
<div>
<p>待完成</p>
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script lang="ts" setup>
defineOptions({ name: 'CrmPermissionList' });
defineProps<{
bizId: number | undefined; // 模块数据编号
bizType: number; // 模块类型
showAction: boolean; // 是否展示操作按钮
}>();
</script>
<template>
<div>
<p>待完成</p>
</div>
</template>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup></script>
<template>
<div>
<p>待完成</p>
</div>
</template>

View File

@@ -0,0 +1,34 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { DocAlert } from '#/components/doc-alert';
</script>
<template>
<Page>
<DocAlert
title="【产品】产品管理、产品分类"
url="https://doc.iocoder.cn/crm/product/"
/>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/product/category/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/product/category/index
代码pull request 贡献给我们
</Button>
</Page>
</template>

View File

@@ -0,0 +1,34 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { DocAlert } from '#/components/doc-alert';
</script>
<template>
<Page>
<DocAlert
title="【产品】产品管理、产品分类"
url="https://doc.iocoder.cn/crm/product/"
/>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/product/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/product/index
代码pull request 贡献给我们
</Button>
</Page>
</template>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup></script>
<template>
<div>
<p>待完成</p>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { DocAlert } from '#/components/doc-alert';
</script>
<template>
<Page>
<DocAlert
title="【回款】回款管理、回款计划"
url="https://doc.iocoder.cn/crm/receivable/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/receivable/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/receivable/index
代码pull request 贡献给我们
</Button>
</Page>
</template>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup></script>
<template>
<div>
<p>待完成</p>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { DocAlert } from '#/components/doc-alert';
</script>
<template>
<Page>
<DocAlert
title="【回款】回款管理、回款计划"
url="https://doc.iocoder.cn/crm/receivable/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/receivable/plan/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/receivable/plan/index
代码pull request 贡献给我们
</Button>
</Page>
</template>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup></script>
<template>
<div>
<p>待完成</p>
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
</script>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/customer/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/customer/index.vue
代码pull request 贡献给我们
</Button>
</Page>
</template>

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
</script>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/funnel/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/funnel/index
代码pull request 贡献给我们
</Button>
</Page>
</template>

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
</script>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/performance/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/performance/index
代码pull request 贡献给我们
</Button>
</Page>
</template>

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
</script>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/portrait/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/portrait/index
代码pull request 贡献给我们
</Button>
</Page>
</template>

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
</script>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/rank/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/crm/statistics/rank/index
代码pull request 贡献给我们
</Button>
</Page>
</template>

View File

@@ -0,0 +1,98 @@
<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: [
111, 2000, 6000, 16_000, 33_333, 55_555, 64_000, 33_333, 18_000,
36_000, 70_000, 42_444, 23_222, 13_000, 8000, 4000, 1200, 333, 222,
111,
],
itemStyle: {
color: '#5ab1ef',
},
smooth: true,
type: 'line',
},
{
areaStyle: {},
data: [
33, 66, 88, 333, 3333, 6200, 20_000, 3000, 1200, 13_000, 22_000,
11_000, 2221, 1201, 390, 198, 60, 30, 22, 11,
],
itemStyle: {
color: '#019680',
},
smooth: true,
type: 'line',
},
],
tooltip: {
axisPointer: {
lineStyle: {
color: '#019680',
width: 1,
},
},
trigger: 'axis',
},
// xAxis: {
// axisTick: {
// show: false,
// },
// boundaryGap: false,
// data: Array.from({ length: 18 }).map((_item, index) => `${index + 6}:00`),
// type: 'category',
// },
xAxis: {
axisTick: {
show: false,
},
boundaryGap: false,
data: Array.from({ length: 18 }).map((_item, index) => `${index + 6}:00`),
splitLine: {
lineStyle: {
type: 'solid',
width: 1,
},
show: true,
},
type: 'category',
},
yAxis: [
{
axisTick: {
show: false,
},
max: 80_000,
splitArea: {
show: true,
},
splitNumber: 4,
type: 'value',
},
],
});
});
</script>
<template>
<EchartsUI ref="chartRef" />
</template>

View File

@@ -0,0 +1,82 @@
<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: ['访问', '趋势'],
},
radar: {
indicator: [
{
name: '网页',
},
{
name: '移动端',
},
{
name: 'Ipad',
},
{
name: '客户端',
},
{
name: '第三方',
},
{
name: '其它',
},
],
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: '访问',
value: [90, 50, 86, 40, 50, 20],
},
{
itemStyle: {
color: '#5ab1ef',
},
name: '趋势',
value: [70, 75, 70, 76, 20, 85],
},
],
itemStyle: {
// borderColor: '#fff',
borderRadius: 10,
borderWidth: 2,
},
symbolSize: 0,
type: 'radar',
},
],
tooltip: {},
});
});
</script>
<template>
<EchartsUI ref="chartRef" />
</template>

View File

@@ -0,0 +1,46 @@
<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', '#2ec7c9'],
data: [
{ name: '外包', value: 500 },
{ name: '定制', value: 310 },
{ name: '技术支持', value: 274 },
{ name: '远程', value: 400 },
].sort((a, b) => {
return a.value - b.value;
}),
name: '商业占比',
radius: '80%',
roseType: 'radius',
type: 'pie',
},
],
tooltip: {
trigger: 'item',
},
});
});
</script>
<template>
<EchartsUI ref="chartRef" />
</template>

View File

@@ -0,0 +1,65 @@
<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',
},
series: [
{
animationDelay() {
return Math.random() * 100;
},
animationEasing: 'exponentialInOut',
animationType: 'scale',
avoidLabelOverlap: false,
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'],
data: [
{ name: '搜索引擎', value: 1048 },
{ name: '直接访问', value: 735 },
{ name: '邮件营销', value: 580 },
{ name: '联盟广告', 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,55 @@
<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,
// color: '#4f69fd',
data: [
3000, 2000, 3333, 5000, 3200, 4200, 3200, 2100, 3000, 5100, 6000,
3200, 4800,
],
type: 'bar',
},
],
tooltip: {
axisPointer: {
lineStyle: {
// color: '#4f69fd',
width: 1,
},
},
trigger: 'axis',
},
xAxis: {
data: Array.from({ length: 12 }).map((_item, index) => `${index + 1}`),
type: 'category',
},
yAxis: {
max: 8000,
splitNumber: 4,
type: 'value',
},
});
});
</script>
<template>
<EchartsUI ref="chartRef" />
</template>

View File

@@ -0,0 +1,90 @@
<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_000,
value: 8000,
},
{
icon: SvgBellIcon,
title: '使用量',
totalTitle: '总使用量',
totalValue: 50_000,
value: 5000,
},
];
const chartTabs: TabOption[] = [
{
label: '流量趋势',
value: 'trends',
},
{
label: '月访问量',
value: 'visits',
},
];
</script>
<template>
<div class="p-5">
<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,260 @@
<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: '#6DB33F',
content: 'github.com/YunaiV/ruoyi-vue-pro',
date: '2025-01-02',
group: 'Spring Boot 单体架构',
icon: 'simple-icons:springboot',
title: 'ruoyi-vue-pro',
url: 'https://github.com/YunaiV/ruoyi-vue-pro',
},
{
color: '#409EFF',
content: 'github.com/yudaocode/yudao-ui-admin-vue3',
date: '2025-02-03',
group: 'Vue3 + element-plus 管理后台',
icon: 'ep:element-plus',
title: 'yudao-ui-admin-vue3',
url: 'https://github.com/yudaocode/yudao-ui-admin-vue3',
},
{
color: '#ff4d4f',
content: 'github.com/yudaocode/yudao-ui-mall-uniapp',
date: '2025-03-04',
group: 'Vue3 + uniapp 商城手机端',
icon: 'icon-park-outline:mall-bag',
title: 'yudao-ui-mall-uniapp',
url: 'https://github.com/yudaocode/yudao-ui-mall-uniapp',
},
{
color: '#1890ff',
content: 'github.com/YunaiV/yudao-cloud',
date: '2025-04-05',
group: 'Spring Cloud 微服务架构',
icon: 'material-symbols:cloud-outline',
title: 'yudao-cloud',
url: 'https://github.com/YunaiV/yudao-cloud',
},
{
color: '#e18525',
content: 'github.com/yudaocode/yudao-ui-admin-vben',
date: '2025-05-06',
group: 'Vue3 + vben5(antd) 管理后台',
icon: 'devicon:antdesign',
title: 'agt-web',
url: 'https://github.com/yudaocode/yudao-ui-admin-vben',
},
{
color: '#2979ff',
content: 'github.com/yudaocode/yudao-ui-admin-uniapp',
date: '2025-06-01',
group: 'Vue3 + uniapp 管理手机端',
icon: 'ant-design:mobile',
title: 'yudao-ui-admin-uniapp',
url: 'https://github.com/yudaocode/yudao-ui-admin-uniapp',
},
];
// 同样,这里的 url 也可以使用以 http 开头的外部链接
const quickNavItems: WorkbenchQuickNavItem[] = [
{
color: '#1fdaca',
icon: 'ion:home-outline',
title: '首页',
url: '/',
},
{
color: '#ff6b6b',
icon: 'ep:shop',
title: '商城中心',
url: '/mall',
},
{
color: '#7c3aed',
icon: 'tabler:ai',
title: 'AI 大模型',
url: '/ai',
},
{
color: '#3fb27f',
icon: 'simple-icons:erpnext',
title: 'ERP 系统',
url: '/erp',
},
{
color: '#4daf1bc9',
icon: 'simple-icons:civicrm',
title: 'CRM 系统',
url: '/crm',
},
{
color: '#1a73e8',
icon: 'fa-solid:hdd',
title: 'IoT 物联网',
url: '/iot',
},
];
const todoItems = ref<WorkbenchTodoItem[]>([
{
completed: false,
content: `系统支持 JDK 8/17/21Vue 2/3`,
date: '2024-07-15 09:30:00',
title: '技术兼容性',
},
{
completed: false,
content: `后端提供 Spring Boot 2.7/3.2 + Cloud 双架构`,
date: '2024-08-30 14:20:00',
title: '架构灵活性',
},
{
completed: false,
content: `全部开源,个人与企业可 100% 直接使用,无需授权`,
date: '2024-07-25 16:45:00',
title: '开源免授权',
},
{
completed: false,
content: `国内使用最广泛的快速开发平台,远超 10w+ 企业使用`,
date: '2024-07-10 11:15:00',
title: '广泛企业认可',
},
]);
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?.nickname }}, 开始您一天的工作吧
</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,173 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraApiAccessLogApi } from '#/api/infra/api-access-log';
import { useAccess } from '@vben/access';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'userId',
label: '用户编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入用户编号',
},
},
{
fieldName: 'userType',
label: '用户类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.USER_TYPE, 'number'),
allowClear: true,
placeholder: '请选择用户类型',
},
},
{
fieldName: 'applicationName',
label: '应用名',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入应用名',
},
},
{
fieldName: 'beginTime',
label: '请求时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
{
fieldName: 'duration',
label: '执行时长',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入执行时长',
},
},
{
fieldName: 'resultCode',
label: '结果码',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入结果码',
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = InfraApiAccessLogApi.ApiAccessLog>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '日志编号',
minWidth: 100,
},
{
field: 'userId',
title: '用户编号',
minWidth: 100,
},
{
field: 'userType',
title: '用户类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.USER_TYPE },
},
},
{
field: 'applicationName',
title: '应用名',
minWidth: 150,
},
{
field: 'requestMethod',
title: '请求方法',
minWidth: 80,
},
{
field: 'requestUrl',
title: '请求地址',
minWidth: 300,
},
{
field: 'beginTime',
title: '请求时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'duration',
title: '执行时长',
minWidth: 120,
formatter: ({ row }) => `${row.duration} ms`,
},
{
field: 'resultCode',
title: '操作结果',
minWidth: 150,
formatter: ({ row }) => {
return row.resultCode === 0 ? '成功' : `失败(${row.resultMsg})`;
},
},
{
field: 'operateModule',
title: '操作模块',
minWidth: 150,
},
{
field: 'operateName',
title: '操作名',
minWidth: 220,
},
{
field: 'operateType',
title: '操作类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_OPERATE_TYPE },
},
},
{
field: 'operation',
title: '操作',
minWidth: 80,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'id',
nameTitle: 'API访问日志',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'detail',
text: '详情',
show: hasAccessByCodes(['infra:api-access-log:query']),
},
],
},
},
];
}

View File

@@ -0,0 +1,110 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { InfraApiAccessLogApi } from '#/api/infra/api-access-log';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
exportApiAccessLog,
getApiAccessLogPage,
} from '#/api/infra/api-access-log';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue';
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportApiAccessLog(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: 'API 访问日志.xls', source: data });
}
/** 查看 API 访问日志详情 */
function onDetail(row: InfraApiAccessLogApi.ApiAccessLog) {
detailModalApi.setData(row).open();
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<InfraApiAccessLogApi.ApiAccessLog>) {
switch (code) {
case 'detail': {
onDetail(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getApiAccessLogPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<InfraApiAccessLogApi.ApiAccessLog>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="系统日志" url="https://doc.iocoder.cn/system-log/" />
</template>
<DetailModal @success="onRefresh" />
<Grid table-title="API 访问日志列表">
<template #toolbar-tools>
<Button
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:api-access-log:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,106 @@
<script lang="ts" setup>
import type { InfraApiAccessLogApi } from '#/api/infra/api-access-log';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { formatDateTime } from '@vben/utils';
import { Descriptions } from 'ant-design-vue';
import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE } from '#/utils';
const formData = ref<InfraApiAccessLogApi.ApiAccessLog>();
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<InfraApiAccessLogApi.ApiAccessLog>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = data;
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="API 访问日志详情"
class="w-1/2"
:show-cancel-button="false"
:show-confirm-button="false"
>
<Descriptions
bordered
:column="1"
size="middle"
class="mx-4"
:label-style="{ width: '110px' }"
>
<Descriptions.Item label="日志编号">
{{ formData?.id }}
</Descriptions.Item>
<Descriptions.Item label="链路追踪">
{{ formData?.traceId }}
</Descriptions.Item>
<Descriptions.Item label="应用名">
{{ formData?.applicationName }}
</Descriptions.Item>
<Descriptions.Item label="用户信息">
{{ formData?.userId }}
<DictTag :type="DICT_TYPE.USER_TYPE" :value="formData?.userType" />
</Descriptions.Item>
<Descriptions.Item label="用户IP">
{{ formData?.userIp }}
</Descriptions.Item>
<Descriptions.Item label="用户UA">
{{ formData?.userAgent }}
</Descriptions.Item>
<Descriptions.Item label="请求信息">
{{ formData?.requestMethod }} {{ formData?.requestUrl }}
</Descriptions.Item>
<Descriptions.Item label="请求参数">
{{ formData?.requestParams }}
</Descriptions.Item>
<Descriptions.Item label="请求结果">
{{ formData?.responseBody }}
</Descriptions.Item>
<Descriptions.Item label="请求时间">
{{ formatDateTime(formData?.beginTime || '') }} ~
{{ formatDateTime(formData?.endTime || '') }}
</Descriptions.Item>
<Descriptions.Item label="请求耗时">
{{ formData?.duration }} ms
</Descriptions.Item>
<Descriptions.Item label="操作结果">
<div v-if="formData?.resultCode === 0">正常</div>
<div v-else-if="formData && formData?.resultCode > 0">
失败 | {{ formData?.resultCode }} | {{ formData?.resultMsg }}
</div>
</Descriptions.Item>
<Descriptions.Item label="操作模块">
{{ formData?.operateModule }}
</Descriptions.Item>
<Descriptions.Item label="操作名">
{{ formData?.operateName }}
</Descriptions.Item>
<Descriptions.Item label="操作类型">
<DictTag
:type="DICT_TYPE.INFRA_OPERATE_TYPE"
:value="formData?.operateType"
/>
</Descriptions.Item>
</Descriptions>
</Modal>
</template>

View File

@@ -0,0 +1,175 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraApiErrorLogApi } from '#/api/infra/api-error-log';
import { useAccess } from '@vben/access';
import {
DICT_TYPE,
getDictOptions,
getRangePickerDefaultProps,
InfraApiErrorLogProcessStatusEnum,
} from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'userId',
label: '用户编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入用户编号',
},
},
{
fieldName: 'userType',
label: '用户类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.USER_TYPE, 'number'),
allowClear: true,
placeholder: '请选择用户类型',
},
},
{
fieldName: 'applicationName',
label: '应用名',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入应用名',
},
},
{
fieldName: 'exceptionTime',
label: '异常时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
{
fieldName: 'processStatus',
label: '处理状态',
component: 'Select',
componentProps: {
options: getDictOptions(
DICT_TYPE.INFRA_API_ERROR_LOG_PROCESS_STATUS,
'number',
),
allowClear: true,
placeholder: '请选择处理状态',
},
defaultValue: InfraApiErrorLogProcessStatusEnum.INIT,
},
];
}
/** 列表的字段 */
export function useGridColumns<T = InfraApiErrorLogApi.ApiErrorLog>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '日志编号',
minWidth: 100,
},
{
field: 'userId',
title: '用户编号',
minWidth: 100,
},
{
field: 'userType',
title: '用户类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.USER_TYPE },
},
},
{
field: 'applicationName',
title: '应用名',
minWidth: 150,
},
{
field: 'requestMethod',
title: '请求方法',
minWidth: 80,
},
{
field: 'requestUrl',
title: '请求地址',
minWidth: 200,
},
{
field: 'exceptionTime',
title: '异常发生时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'exceptionName',
title: '异常名',
minWidth: 180,
},
{
field: 'processStatus',
title: '处理状态',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_API_ERROR_LOG_PROCESS_STATUS },
},
},
{
field: 'operation',
title: '操作',
minWidth: 200,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'id',
nameTitle: 'API错误日志',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'detail',
text: '详情',
show: hasAccessByCodes(['infra:api-error-log:query']),
},
{
code: 'done',
text: '已处理',
show: (row: InfraApiErrorLogApi.ApiErrorLog) => {
return (
row.processStatus === InfraApiErrorLogProcessStatusEnum.INIT &&
hasAccessByCodes(['infra:api-error-log:update-status'])
);
},
},
{
code: 'ignore',
text: '已忽略',
show: (row: InfraApiErrorLogApi.ApiErrorLog) => {
return (
row.processStatus === InfraApiErrorLogProcessStatusEnum.INIT &&
hasAccessByCodes(['infra:api-error-log:update-status'])
);
},
},
],
},
},
];
}

View File

@@ -0,0 +1,132 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { InfraApiErrorLogApi } from '#/api/infra/api-error-log';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { Download } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
exportApiErrorLog,
getApiErrorLogPage,
updateApiErrorLogStatus,
} from '#/api/infra/api-error-log';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { InfraApiErrorLogProcessStatusEnum } from '#/utils';
import { useGridColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue';
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportApiErrorLog(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: 'API 错误日志.xls', source: data });
}
/** 查看 API 错误日志详情 */
function onDetail(row: InfraApiErrorLogApi.ApiErrorLog) {
detailModalApi.setData(row).open();
}
/** 处理已处理 / 已忽略的操作 */
async function onProcess(id: number, processStatus: number) {
confirm({
content: `确认标记为${InfraApiErrorLogProcessStatusEnum.DONE ? '已处理' : '已忽略'}?`,
}).then(async () => {
await updateApiErrorLogStatus(id, processStatus);
// 关闭并提示
message.success($t('ui.actionMessage.operationSuccess'));
onRefresh();
});
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<InfraApiErrorLogApi.ApiErrorLog>) {
switch (code) {
case 'detail': {
onDetail(row);
break;
}
case 'done': {
onProcess(row.id, InfraApiErrorLogProcessStatusEnum.DONE);
break;
}
case 'ignore': {
onProcess(row.id, InfraApiErrorLogProcessStatusEnum.IGNORE);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getApiErrorLogPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<InfraApiErrorLogApi.ApiErrorLog>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="系统日志" url="https://doc.iocoder.cn/system-log/" />
</template>
<DetailModal @success="onRefresh" />
<Grid table-title="API 错误日志列表">
<template #toolbar-tools>
<Button
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:api-error-log:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,103 @@
<script lang="ts" setup>
import type { InfraApiErrorLogApi } from '#/api/infra/api-error-log';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { formatDateTime } from '@vben/utils';
import { Descriptions, Input } from 'ant-design-vue';
import { DictTag } from '#/components/dict-tag';
import { DICT_TYPE } from '#/utils';
const formData = ref<InfraApiErrorLogApi.ApiErrorLog>();
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<InfraApiErrorLogApi.ApiErrorLog>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = data;
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="API错误日志详情"
class="w-1/2"
:show-cancel-button="false"
:show-confirm-button="false"
>
<Descriptions
bordered
:column="1"
size="middle"
class="mx-4"
:label-style="{ width: '110px' }"
>
<Descriptions.Item label="日志编号">
{{ formData?.id }}
</Descriptions.Item>
<Descriptions.Item label="链路追踪">
{{ formData?.traceId }}
</Descriptions.Item>
<Descriptions.Item label="应用名">
{{ formData?.applicationName }}
</Descriptions.Item>
<Descriptions.Item label="用户编号">
{{ formData?.userId }}
<DictTag :type="DICT_TYPE.USER_TYPE" :value="formData?.userType" />
</Descriptions.Item>
<Descriptions.Item label="用户IP">
{{ formData?.userIp }}
</Descriptions.Item>
<Descriptions.Item label="用户UA">
{{ formData?.userAgent }}
</Descriptions.Item>
<Descriptions.Item label="请求信息">
{{ formData?.requestMethod }} {{ formData?.requestUrl }}
</Descriptions.Item>
<Descriptions.Item label="请求参数">
{{ formData?.requestParams }}
</Descriptions.Item>
<Descriptions.Item label="异常时间">
{{ formatDateTime(formData?.exceptionTime || '') }}
</Descriptions.Item>
<Descriptions.Item label="异常名">
{{ formData?.exceptionName }}
</Descriptions.Item>
<Descriptions.Item v-if="formData?.exceptionStackTrace" label="异常堆栈">
<Input.TextArea
:value="formData?.exceptionStackTrace"
:auto-size="{ maxRows: 20 }"
readonly
/>
</Descriptions.Item>
<Descriptions.Item label="处理状态">
<DictTag
:type="DICT_TYPE.INFRA_API_ERROR_LOG_PROCESS_STATUS"
:value="formData?.processStatus"
/>
</Descriptions.Item>
<Descriptions.Item v-if="formData?.processUserId" label="处理人">
{{ formData?.processUserId }}
</Descriptions.Item>
<Descriptions.Item v-if="formData?.processTime" label="处理时间">
{{ formatDateTime(formData?.processTime || '') }}
</Descriptions.Item>
</Descriptions>
</Modal>
</template>

View File

@@ -0,0 +1,182 @@
<!-- eslint-disable no-useless-escape -->
<script setup lang="ts">
import { onMounted, ref, unref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { isString } from '@vben/utils';
import formCreate from '@form-create/ant-design-vue';
import FcDesigner from '@form-create/antd-designer';
import { useClipboard } from '@vueuse/core';
import { Button, message } from 'ant-design-vue';
import hljs from 'highlight.js';
import xml from 'highlight.js/lib/languages/java';
import json from 'highlight.js/lib/languages/json';
import { useFormCreateDesigner } from '#/components/form-create';
import 'highlight.js/styles/github.css';
defineOptions({ name: 'InfraBuild' });
const [Modal, modalApi] = useVbenModal();
const designer = ref(); // 表单设计器
// 表单设计器配置
const designerConfig = ref({
switchType: [], // 是否可以切换组件类型,或者可以相互切换的字段
autoActive: true, // 是否自动选中拖入的组件
useTemplate: false, // 是否生成vue2语法的模板组件
formOptions: {
form: {
labelWidth: '100px', // 设置默认的 label 宽度为 100px
},
}, // 定义表单配置默认值
fieldReadonly: false, // 配置field是否可以编辑
hiddenDragMenu: false, // 隐藏拖拽操作按钮
hiddenDragBtn: false, // 隐藏拖拽按钮
hiddenMenu: [], // 隐藏部分菜单
hiddenItem: [], // 隐藏部分组件
hiddenItemConfig: {}, // 隐藏组件的部分配置项
disabledItemConfig: {}, // 禁用组件的部分配置项
showSaveBtn: false, // 是否显示保存按钮
showConfig: true, // 是否显示右侧的配置界面
showBaseForm: true, // 是否显示组件的基础配置表单
showControl: true, // 是否显示组件联动
showPropsForm: true, // 是否显示组件的属性配置表单
showEventForm: true, // 是否显示组件的事件配置表单
showValidateForm: true, // 是否显示组件的验证配置表单
showFormConfig: true, // 是否显示表单配置
showInputData: true, // 是否显示录入按钮
showDevice: true, // 是否显示多端适配选项
appendConfigData: [], // 定义渲染规则所需的formData
});
const dialogVisible = ref(false); // 弹窗的是否展示
const dialogTitle = ref(''); // 弹窗的标题
const formType = ref(-1); // 表单的类型0 - 生成 JSON1 - 生成 Options2 - 生成组件
const formData = ref(''); // 表单数据
useFormCreateDesigner(designer); // 表单设计器增强
/** 打开弹窗 */
const openModel = (title: string) => {
dialogVisible.value = true;
dialogTitle.value = title;
modalApi.open();
};
/** 生成 JSON */
const showJson = () => {
openModel('生成 JSON');
formType.value = 0;
formData.value = designer.value.getRule();
};
/** 生成 Options */
const showOption = () => {
openModel('生成 Options');
formType.value = 1;
formData.value = designer.value.getOption();
};
/** 生成组件 */
const showTemplate = () => {
openModel('生成组件');
formType.value = 2;
formData.value = makeTemplate();
};
const makeTemplate = () => {
const rule = designer.value.getRule();
const opt = designer.value.getOption();
return `<template>
<form-create
v-model:api="fApi"
:rule="rule"
:option="option"
@submit="onSubmit"
></form-create>
</template>
<script setup lang=ts>
const faps = ref(null)
const rule = ref('')
const option = ref('')
const init = () => {
rule.value = formCreate.parseJson('${formCreate.toJson(rule).replaceAll('\\', '\\\\')}')
option.value = formCreate.parseJson('${JSON.stringify(opt, null, 2)}')
}
const onSubmit = (formData) => {
//todo 提交表单
}
init()
<\/script>`;
};
/** 复制 */
const copy = async (text: string) => {
const textToCopy = JSON.stringify(text, null, 2);
const { copy, copied, isSupported } = useClipboard({ source: textToCopy });
if (isSupported) {
await copy();
if (unref(copied)) {
message.success('复制成功');
}
} else {
message.error('复制失败');
}
};
/**
* 代码高亮
*/
const highlightedCode = (code: string) => {
// 处理语言和代码
let language = 'json';
if (formType.value === 2) {
language = 'xml';
}
// debugger
if (!isString(code)) {
code = JSON.stringify(code, null, 2);
}
// 高亮
const result = hljs.highlight(code, { language, ignoreIllegals: true });
return result.value || '&nbsp;';
};
/** 初始化 */
onMounted(async () => {
// 注册代码高亮的各种语言
hljs.registerLanguage('xml', xml);
hljs.registerLanguage('json', json);
});
</script>
<template>
<Page auto-content-height>
<FcDesigner ref="designer" height="90vh" :config="designerConfig">
<template #handle>
<Button size="small" type="primary" ghost @click="showJson">
生成JSON
</Button>
<Button size="small" type="primary" ghost @click="showOption">
生成Options
</Button>
<Button size="small" type="primary" ghost @click="showTemplate">
生成组件
</Button>
</template>
</FcDesigner>
<!-- 弹窗表单预览 -->
<Modal :title="dialogTitle" :footer="false" :fullscreen-button="false">
<div>
<Button style="float: right" @click="copy(formData)"> 复制 </Button>
<div>
<pre><code v-dompurify-html="highlightedCode(formData)" class="hljs"></code></pre>
</div>
</div>
</Modal>
</Page>
</template>

View File

@@ -0,0 +1,591 @@
import type { Recordable } from '@vben/types';
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraCodegenApi } from '#/api/infra/codegen';
import type { SystemMenuApi } from '#/api/system/menu';
import { h } from 'vue';
import { useAccess } from '@vben/access';
import { IconifyIcon } from '@vben/icons';
import { handleTree } from '@vben/utils';
import { getDataSourceConfigList } from '#/api/infra/data-source-config';
import { getMenuList } from '#/api/system/menu';
import { $t } from '#/locales';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 导入数据库表的表单 */
export function useImportTableFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'dataSourceConfigId',
label: '数据源',
component: 'ApiSelect',
componentProps: {
api: async () => {
const data = await getDataSourceConfigList();
return data.map((item) => ({
label: item.name,
value: item.id,
}));
},
autoSelect: 'first',
placeholder: '请选择数据源',
},
rules: 'selectRequired',
},
{
fieldName: 'name',
label: '表名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入表名称',
},
},
{
fieldName: 'comment',
label: '表描述',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入表描述',
},
},
];
}
/** 导入数据库表表格列定义 */
export function useImportTableColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{ field: 'name', title: '表名称', minWidth: 200 },
{ field: 'comment', title: '表描述', minWidth: 200 },
];
}
/** 基本信息表单的 schema */
export function useBasicInfoFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'tableName',
label: '表名称',
component: 'Input',
componentProps: {
placeholder: '请输入仓库名称',
},
rules: 'required',
},
{
fieldName: 'tableComment',
label: '表描述',
component: 'Input',
componentProps: {
placeholder: '请输入表描述',
},
rules: 'required',
},
{
fieldName: 'className',
label: '实体类名称',
component: 'Input',
componentProps: {
placeholder: '请输入实体类名称',
},
rules: 'required',
help: '默认去除表名的前缀。如果存在重复,则需要手动添加前缀,避免 MyBatis 报 Alias 重复的问题。',
},
{
fieldName: 'author',
label: '作者',
component: 'Input',
componentProps: {
placeholder: '请输入作者',
},
rules: 'required',
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
rows: 3,
placeholder: '请输入备注',
},
formItemClass: 'md:col-span-2',
},
];
}
/** 生成信息表单基础 schema */
export function useGenerationInfoBaseFormSchema(): VbenFormSchema[] {
return [
{
component: 'Select',
fieldName: 'templateType',
label: '生成模板',
componentProps: {
options: getDictOptions(
DICT_TYPE.INFRA_CODEGEN_TEMPLATE_TYPE,
'number',
),
class: 'w-full',
},
rules: 'selectRequired',
},
{
component: 'Select',
fieldName: 'frontType',
label: '前端类型',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_CODEGEN_FRONT_TYPE, 'number'),
class: 'w-full',
},
rules: 'selectRequired',
},
{
component: 'Select',
fieldName: 'scene',
label: '生成场景',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_CODEGEN_SCENE, 'number'),
class: 'w-full',
},
rules: 'selectRequired',
},
{
fieldName: 'parentMenuId',
label: '上级菜单',
help: '分配到指定菜单下,例如 系统管理',
component: 'ApiTreeSelect',
componentProps: {
allowClear: true,
api: async () => {
const data = await getMenuList();
data.unshift({
id: 0,
name: '顶级菜单',
} as SystemMenuApi.Menu);
return handleTree(data);
},
class: 'w-full',
labelField: 'name',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择上级菜单',
filterTreeNode(input: string, node: Recordable<any>) {
if (!input || input.length === 0) {
return true;
}
const name: string = node.label ?? '';
if (!name) return false;
return name.includes(input) || $t(name).includes(input);
},
showSearch: true,
treeDefaultExpandedKeys: [0],
},
rules: 'selectRequired',
renderComponentContent() {
return {
title({ label, icon }: { icon: string; label: string }) {
const components = [];
if (!label) return '';
if (icon) {
components.push(h(IconifyIcon, { class: 'size-4', icon }));
}
components.push(h('span', { class: '' }, $t(label || '')));
return h('div', { class: 'flex items-center gap-1' }, components);
},
};
},
},
{
component: 'Input',
fieldName: 'moduleName',
label: '模块名',
help: '模块名,即一级目录,例如 system、infra、tool 等等',
rules: 'required',
},
{
component: 'Input',
fieldName: 'businessName',
label: '业务名',
help: '业务名,即二级目录,例如 user、permission、dict 等等',
rules: 'required',
},
{
component: 'Input',
fieldName: 'className',
label: '类名称',
help: '类名称首字母大写例如SysUser、SysMenu、SysDictData 等等',
rules: 'required',
},
{
component: 'Input',
fieldName: 'classComment',
label: '类描述',
help: '用作类描述,例如 用户',
rules: 'required',
},
];
}
/** 树表信息 schema */
export function useGenerationInfoTreeFormSchema(
columns: InfraCodegenApi.CodegenColumn[] = [],
): VbenFormSchema[] {
return [
{
component: 'Divider',
fieldName: 'treeDivider',
label: '',
renderComponentContent: () => {
return {
default: () => ['树表信息'],
};
},
formItemClass: 'md:col-span-2',
},
{
component: 'Select',
fieldName: 'treeParentColumnId',
label: '父编号字段',
help: '树显示的父编码字段名,例如 parent_Id',
componentProps: {
class: 'w-full',
allowClear: true,
placeholder: '请选择',
options: columns.map((column) => ({
label: column.columnName,
value: column.id,
})),
},
rules: 'selectRequired',
},
{
component: 'Select',
fieldName: 'treeNameColumnId',
label: '名称字段',
help: '树节点显示的名称字段,一般是 name',
componentProps: {
class: 'w-full',
allowClear: true,
placeholder: '请选择名称字段',
options: columns.map((column) => ({
label: column.columnName,
value: column.id,
})),
},
rules: 'selectRequired',
},
];
}
/** 主子表信息 schema */
export function useGenerationInfoSubTableFormSchema(
columns: InfraCodegenApi.CodegenColumn[] = [],
tables: InfraCodegenApi.CodegenTable[] = [],
): VbenFormSchema[] {
return [
{
component: 'Divider',
fieldName: 'subDivider',
label: '',
renderComponentContent: () => {
return {
default: () => ['主子表信息'],
};
},
formItemClass: 'md:col-span-2',
},
{
component: 'Select',
fieldName: 'masterTableId',
label: '关联的主表',
help: '关联主表(父表)的表名, 如system_user',
componentProps: {
class: 'w-full',
allowClear: true,
placeholder: '请选择',
options: tables.map((table) => ({
label: `${table.tableName}${table.tableComment}`,
value: table.id,
})),
},
rules: 'selectRequired',
},
{
component: 'Select',
fieldName: 'subJoinColumnId',
label: '子表关联的字段',
help: '子表关联的字段, 如user_id',
componentProps: {
class: 'w-full',
allowClear: true,
placeholder: '请选择',
options: columns.map((column) => ({
label: `${column.columnName}:${column.columnComment}`,
value: column.id,
})),
},
rules: 'selectRequired',
},
{
component: 'RadioGroup',
fieldName: 'subJoinMany',
label: '关联关系',
help: '主表与子表的关联关系',
componentProps: {
class: 'w-full',
allowClear: true,
placeholder: '请选择',
options: [
{
label: '一对多',
value: true,
},
{
label: '一对一',
value: 'false',
},
],
},
rules: 'required',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'tableName',
label: '表名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入表名称',
},
},
{
fieldName: 'tableComment',
label: '表描述',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入表描述',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = InfraCodegenApi.CodegenTable>(
onActionClick: OnActionClickFn<T>,
getDataSourceConfigName?: (dataSourceConfigId: number) => string | undefined,
): VxeTableGridOptions['columns'] {
return [
{
field: 'dataSourceConfigId',
title: '数据源',
minWidth: 120,
formatter: (row) => getDataSourceConfigName?.(row.cellValue) || '-',
},
{
field: 'tableName',
title: '表名称',
minWidth: 200,
},
{
field: 'tableComment',
title: '表描述',
minWidth: 200,
},
{
field: 'className',
title: '实体',
minWidth: 200,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
width: 300,
fixed: 'right',
align: 'center',
cellRender: {
attrs: {
nameField: 'tableName',
nameTitle: '代码生成',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'preview',
text: '预览',
show: hasAccessByCodes(['infra:codegen:preview']),
},
{
code: 'edit',
show: hasAccessByCodes(['infra:codegen:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['infra:codegen:delete']),
},
{
code: 'sync',
text: '同步',
show: hasAccessByCodes(['infra:codegen:update']),
},
{
code: 'generate',
text: '生成代码',
show: hasAccessByCodes(['infra:codegen:download']),
},
],
},
},
];
}
/** 代码生成表格列定义 */
export function useCodegenColumnTableColumns(): VxeTableGridOptions['columns'] {
return [
{ field: 'columnName', title: '字段列名', minWidth: 130 },
{
field: 'columnComment',
title: '字段描述',
minWidth: 100,
slots: { default: 'columnComment' },
},
{ field: 'dataType', title: '物理类型', minWidth: 100 },
{
field: 'javaType',
title: 'Java 类型',
minWidth: 130,
slots: { default: 'javaType' },
params: {
options: [
{ label: 'Long', value: 'Long' },
{ label: 'String', value: 'String' },
{ label: 'Integer', value: 'Integer' },
{ label: 'Double', value: 'Double' },
{ label: 'BigDecimal', value: 'BigDecimal' },
{ label: 'LocalDateTime', value: 'LocalDateTime' },
{ label: 'Boolean', value: 'Boolean' },
],
},
},
{
field: 'javaField',
title: 'Java 属性',
minWidth: 100,
slots: { default: 'javaField' },
},
{
field: 'createOperation',
title: '插入',
width: 40,
slots: { default: 'createOperation' },
},
{
field: 'updateOperation',
title: '编辑',
width: 40,
slots: { default: 'updateOperation' },
},
{
field: 'listOperationResult',
title: '列表',
width: 40,
slots: { default: 'listOperationResult' },
},
{
field: 'listOperation',
title: '查询',
width: 40,
slots: { default: 'listOperation' },
},
{
field: 'listOperationCondition',
title: '查询方式',
minWidth: 100,
slots: { default: 'listOperationCondition' },
params: {
options: [
{ label: '=', value: '=' },
{ label: '!=', value: '!=' },
{ label: '>', value: '>' },
{ label: '>=', value: '>=' },
{ label: '<', value: '<' },
{ label: '<=', value: '<=' },
{ label: 'LIKE', value: 'LIKE' },
{ label: 'BETWEEN', value: 'BETWEEN' },
],
},
},
{
field: 'nullable',
title: '允许空',
width: 60,
slots: { default: 'nullable' },
},
{
field: 'htmlType',
title: '显示类型',
width: 130,
slots: { default: 'htmlType' },
params: {
options: [
{ label: '文本框', value: 'input' },
{ label: '文本域', value: 'textarea' },
{ label: '下拉框', value: 'select' },
{ label: '单选框', value: 'radio' },
{ label: '复选框', value: 'checkbox' },
{ label: '日期控件', value: 'datetime' },
{ label: '图片上传', value: 'imageUpload' },
{ label: '文件上传', value: 'fileUpload' },
{ label: '富文本控件', value: 'editor' },
],
},
},
{
field: 'dictType',
title: '字典类型',
width: 120,
slots: { default: 'dictType' },
},
{
field: 'example',
title: '示例',
minWidth: 100,
slots: { default: 'example' },
},
];
}

View File

@@ -0,0 +1,169 @@
<script lang="ts" setup>
import type { InfraCodegenApi } from '#/api/infra/codegen';
import { ref, unref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { Button, message, Steps } from 'ant-design-vue';
import { getCodegenTable, updateCodegenTable } from '#/api/infra/codegen';
import { $t } from '#/locales';
import BasicInfo from '../modules/basic-info.vue';
import ColumnInfo from '../modules/column-info.vue';
import GenerationInfo from '../modules/generation-info.vue';
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const currentStep = ref(0);
const formData = ref<InfraCodegenApi.CodegenDetail>({
table: {} as InfraCodegenApi.CodegenTable,
columns: [],
});
/** 表单引用 */
const basicInfoRef = ref<InstanceType<typeof BasicInfo>>();
const columnInfoRef = ref<InstanceType<typeof ColumnInfo>>();
const generateInfoRef = ref<InstanceType<typeof GenerationInfo>>();
/** 获取详情数据 */
const getDetail = async () => {
const id = route.query.id as any;
if (!id) {
return;
}
loading.value = true;
try {
formData.value = await getCodegenTable(id);
} finally {
loading.value = false;
}
};
/** 提交表单 */
const submitForm = async () => {
// 表单验证
const basicInfoValid = await basicInfoRef.value?.validate();
if (!basicInfoValid) {
message.warn('保存失败,原因:基本信息表单校验失败请检查!!!');
return;
}
const generateInfoValid = await generateInfoRef.value?.validate();
if (!generateInfoValid) {
message.warn('保存失败,原因:生成信息表单校验失败请检查!!!');
return;
}
// 提交表单
const hideLoading = message.loading({
content: $t('ui.actionMessage.updating'),
duration: 0,
key: 'action_process_msg',
});
try {
// 拼接相关信息
const basicInfo = await basicInfoRef.value?.getValues();
const columns = columnInfoRef.value?.getData() || unref(formData).columns;
const generateInfo = await generateInfoRef.value?.getValues();
await updateCodegenTable({
table: { ...unref(formData).table, ...basicInfo, ...generateInfo },
columns,
});
// 关闭并提示
message.success($t('ui.actionMessage.operationSuccess'));
close();
} catch (error) {
console.error('保存失败', error);
} finally {
hideLoading();
}
};
const tabs = useTabs();
/** 返回列表 */
const close = () => {
tabs.closeCurrentTab();
router.push('/infra/codegen');
};
/** 下一步 */
const nextStep = async () => {
currentStep.value += 1;
};
/** 上一步 */
const prevStep = () => {
if (currentStep.value > 0) {
currentStep.value -= 1;
}
};
/** 步骤配置 */
const steps = [
{
title: '基本信息',
},
{
title: '字段信息',
},
{
title: '生成信息',
},
];
// 初始化
getDetail();
</script>
<template>
<Page auto-content-height v-loading="loading">
<div
class="flex h-[95%] flex-col rounded-md bg-white p-4 dark:bg-[#1f1f1f] dark:text-gray-300"
>
<Steps
type="navigation"
v-model:current="currentStep"
class="mb-8 rounded shadow-sm dark:bg-[#141414]"
>
<Steps.Step
v-for="(step, index) in steps"
:key="index"
:title="step.title"
/>
</Steps>
<div class="flex-1 overflow-auto py-4">
<!-- 根据当前步骤显示对应的组件 -->
<BasicInfo
v-show="currentStep === 0"
ref="basicInfoRef"
:table="formData.table"
/>
<ColumnInfo
v-show="currentStep === 1"
ref="columnInfoRef"
:columns="formData.columns"
/>
<GenerationInfo
v-show="currentStep === 2"
ref="generateInfoRef"
:table="formData.table"
:columns="formData.columns"
/>
</div>
<div class="mt-4 flex justify-end space-x-2">
<Button v-show="currentStep > 0" @click="prevStep">上一步</Button>
<Button v-show="currentStep < steps.length - 1" @click="nextStep">
下一步
</Button>
<Button type="primary" :loading="loading" @click="submitForm">
保存
</Button>
</div>
</div>
</Page>
</template>

View File

@@ -0,0 +1,231 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { InfraCodegenApi } from '#/api/infra/codegen';
import type { InfraDataSourceConfigApi } from '#/api/infra/data-source-config';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
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 {
deleteCodegenTable,
downloadCodegen,
getCodegenTablePage,
syncCodegenFromDB,
} from '#/api/infra/codegen';
import { getDataSourceConfigList } from '#/api/infra/data-source-config';
import { DocAlert } from '#/components/doc-alert';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import ImportTable from './modules/import-table.vue';
import PreviewCode from './modules/preview-code.vue';
const router = useRouter();
const dataSourceConfigList = ref<InfraDataSourceConfigApi.DataSourceConfig[]>(
[],
);
/** 获取数据源名称 */
const getDataSourceConfigName = (dataSourceConfigId: number) => {
return dataSourceConfigList.value.find(
(item) => item.id === dataSourceConfigId,
)?.name;
};
const [ImportModal, importModalApi] = useVbenModal({
connectedComponent: ImportTable,
destroyOnClose: true,
});
const [PreviewModal, previewModalApi] = useVbenModal({
connectedComponent: PreviewCode,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导入表格 */
function onImport() {
importModalApi.open();
}
/** 预览代码 */
function onPreview(row: InfraCodegenApi.CodegenTable) {
previewModalApi.setData(row).open();
}
/** 编辑表格 */
function onEdit(row: InfraCodegenApi.CodegenTable) {
router.push({ name: 'InfraCodegenEdit', query: { id: row.id } });
}
/** 删除代码生成配置 */
async function onDelete(row: InfraCodegenApi.CodegenTable) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.tableName]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteCodegenTable(row.id);
message.success($t('ui.actionMessage.deleteSuccess', [row.tableName]));
onRefresh();
} finally {
hideLoading();
}
}
/** 同步数据库 */
async function onSync(row: InfraCodegenApi.CodegenTable) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.updating', [row.tableName]),
duration: 0,
key: 'action_process_msg',
});
try {
await syncCodegenFromDB(row.id);
message.success($t('ui.actionMessage.updateSuccess', [row.tableName]));
onRefresh();
} finally {
hideLoading();
}
}
/** 生成代码 */
async function onGenerate(row: InfraCodegenApi.CodegenTable) {
const hideLoading = message.loading({
content: '正在生成代码...',
duration: 0,
key: 'action_process_msg',
});
try {
const res = await downloadCodegen(row.id);
const blob = new Blob([res], { type: 'application/zip' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `codegen-${row.className}.zip`;
link.click();
window.URL.revokeObjectURL(url);
message.success('代码生成成功');
} finally {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<InfraCodegenApi.CodegenTable>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
case 'generate': {
onGenerate(row);
break;
}
case 'preview': {
onPreview(row);
break;
}
case 'sync': {
onSync(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick, getDataSourceConfigName),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCodegenTablePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<InfraCodegenApi.CodegenTable>,
});
/** 获取数据源配置列表 */
async function initDataSourceConfig() {
try {
dataSourceConfigList.value = await getDataSourceConfigList();
} catch (error) {
console.error('获取数据源配置失败', error);
}
}
/** 初始化 */
initDataSourceConfig();
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="代码生成(单表)"
url="https://doc.iocoder.cn/new-feature/"
/>
<DocAlert
title="代码生成(树表)"
url="https://doc.iocoder.cn/new-feature/tree/"
/>
<DocAlert
title="代码生成(主子表)"
url="https://doc.iocoder.cn/new-feature/master-sub/"
/>
<DocAlert title="单元测试" url="https://doc.iocoder.cn/unit-test/" />
</template>
<ImportModal @success="onRefresh" />
<PreviewModal />
<Grid table-title="代码生成列表">
<template #toolbar-tools>
<Button
type="primary"
@click="onImport"
v-access:code="['infra:codegen:create']"
>
<Plus class="size-5" />
导入
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,45 @@
<script lang="ts" setup>
import type { InfraCodegenApi } from '#/api/infra/codegen';
import { watch } from 'vue';
import { useVbenForm } from '#/adapter/form';
import { useBasicInfoFormSchema } from '../data';
const props = defineProps<{
table: InfraCodegenApi.CodegenTable;
}>();
/** 表单实例 */
const [Form, formApi] = useVbenForm({
wrapperClass: 'grid grid-cols-1 md:grid-cols-2 gap-4', // 配置表单布局为两列
schema: useBasicInfoFormSchema(),
layout: 'horizontal',
showDefaultActions: false,
});
/** 动态更新表单值 */
watch(
() => props.table,
(val: any) => {
if (!val) {
return;
}
formApi.setValues(val);
},
{ immediate: true },
);
/** 暴露出表单校验方法和表单值获取方法 */
defineExpose({
validate: async () => {
const { valid } = await formApi.validate();
return valid;
},
getValues: formApi.getValues,
});
</script>
<template>
<Form />
</template>

View File

@@ -0,0 +1,160 @@
<script lang="ts" setup>
import type { InfraCodegenApi } from '#/api/infra/codegen';
import type { SystemDictTypeApi } from '#/api/system/dict/type';
import { nextTick, onMounted, ref, watch } from 'vue';
import { Checkbox, Input, Select } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getSimpleDictTypeList } from '#/api/system/dict/type';
import { useCodegenColumnTableColumns } from '../data';
const props = defineProps<{
columns?: InfraCodegenApi.CodegenColumn[];
}>();
/** 表格配置 */
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useCodegenColumnTableColumns(),
border: true,
showOverflow: true,
autoResize: true,
keepSource: true,
rowConfig: {
keyField: 'id',
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
},
});
/** 监听外部传入的列数据 */
watch(
() => props.columns,
async (columns) => {
if (!columns) {
return;
}
await nextTick();
gridApi.grid?.loadData(columns);
},
{
immediate: true,
},
);
/** 提供获取表格数据的方法供父组件调用 */
defineExpose({
getData: (): InfraCodegenApi.CodegenColumn[] => gridApi.grid.getData(),
});
/** 初始化 */
const dictTypeOptions = ref<SystemDictTypeApi.DictType[]>([]); // 字典类型选项
onMounted(async () => {
dictTypeOptions.value = await getSimpleDictTypeList();
});
</script>
<template>
<Grid>
<!-- 字段描述 -->
<template #columnComment="{ row }">
<Input v-model:value="row.columnComment" />
</template>
<!-- Java 类型 -->
<template #javaType="{ row, column }">
<Select v-model:value="row.javaType" style="width: 100%">
<Select.Option
v-for="option in column.params.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</Select.Option>
</Select>
</template>
<!-- Java 属性 -->
<template #javaField="{ row }">
<Input v-model:value="row.javaField" />
</template>
<!-- 插入 -->
<template #createOperation="{ row }">
<Checkbox v-model:checked="row.createOperation" />
</template>
<!-- 编辑 -->
<template #updateOperation="{ row }">
<Checkbox v-model:checked="row.updateOperation" />
</template>
<!-- 列表 -->
<template #listOperationResult="{ row }">
<Checkbox v-model:checked="row.listOperationResult" />
</template>
<!-- 查询 -->
<template #listOperation="{ row }">
<Checkbox v-model:checked="row.listOperation" />
</template>
<!-- 查询方式 -->
<template #listOperationCondition="{ row, column }">
<Select v-model:value="row.listOperationCondition" class="w-full">
<Select.Option
v-for="option in column.params.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</Select.Option>
</Select>
</template>
<!-- 允许空 -->
<template #nullable="{ row }">
<Checkbox v-model:checked="row.nullable" />
</template>
<!-- 显示类型 -->
<template #htmlType="{ row, column }">
<Select v-model:value="row.htmlType" class="w-full">
<Select.Option
v-for="option in column.params.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</Select.Option>
</Select>
</template>
<!-- 字典类型 -->
<template #dictType="{ row }">
<Select
v-model:value="row.dictType"
class="w-full"
allow-clear
show-search
>
<Select.Option
v-for="option in dictTypeOptions"
:key="option.type"
:value="option.type"
>
{{ option.name }}
</Select.Option>
</Select>
</template>
<!-- 示例 -->
<template #example="{ row }">
<Input v-model:value="row.example" />
</template>
</Grid>
</template>

View File

@@ -0,0 +1,172 @@
<script lang="ts" setup>
import type { InfraCodegenApi } from '#/api/infra/codegen';
import { computed, ref, watch } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import { getCodegenTableList } from '#/api/infra/codegen';
import { InfraCodegenTemplateTypeEnum } from '#/utils';
import {
useGenerationInfoBaseFormSchema,
useGenerationInfoSubTableFormSchema,
useGenerationInfoTreeFormSchema,
} from '../data';
const props = defineProps<{
columns?: InfraCodegenApi.CodegenColumn[];
table?: InfraCodegenApi.CodegenTable;
}>();
const tables = ref<InfraCodegenApi.CodegenTable[]>([]);
/** 计算当前模板类型 */
const currentTemplateType = ref<number>();
const isTreeTable = computed(
() => currentTemplateType.value === InfraCodegenTemplateTypeEnum.TREE,
);
const isSubTable = computed(
() => currentTemplateType.value === InfraCodegenTemplateTypeEnum.SUB,
);
/** 基础表单实例 */
const [BaseForm, baseFormApi] = useVbenForm({
wrapperClass: 'grid grid-cols-1 md:grid-cols-2 gap-4', // 配置表单布局为两列
layout: 'horizontal',
showDefaultActions: false,
schema: useGenerationInfoBaseFormSchema(),
handleValuesChange: (values) => {
// 监听模板类型变化
if (
values.templateType !== undefined &&
values.templateType !== currentTemplateType.value
) {
currentTemplateType.value = values.templateType;
}
},
});
/** 树表信息表单实例 */
const [TreeForm, treeFormApi] = useVbenForm({
wrapperClass: 'grid grid-cols-1 md:grid-cols-2 gap-4', // 配置表单布局为两列
layout: 'horizontal',
showDefaultActions: false,
schema: [],
});
/** 主子表信息表单实例 */
const [SubForm, subFormApi] = useVbenForm({
wrapperClass: 'grid grid-cols-1 md:grid-cols-2 gap-4', // 配置表单布局为两列
layout: 'horizontal',
showDefaultActions: false,
schema: [],
});
/** 更新树表信息表单 schema */
function updateTreeSchema(): void {
treeFormApi.setState({
schema: useGenerationInfoTreeFormSchema(props.columns),
});
}
/** 更新主子表信息表单 schema */
function updateSubSchema(): void {
subFormApi.setState({
schema: useGenerationInfoSubTableFormSchema(props.columns, tables.value),
});
}
/** 获取合并的表单值 */
async function getAllFormValues(): Promise<Record<string, any>> {
// 基础表单值
const baseValues = await baseFormApi.getValues();
// 根据模板类型获取对应的额外表单值
let extraValues = {};
if (isTreeTable.value) {
extraValues = await treeFormApi.getValues();
} else if (isSubTable.value) {
extraValues = await subFormApi.getValues();
}
// 合并表单值
return { ...baseValues, ...extraValues };
}
/** 验证所有表单 */
async function validateAllForms() {
// 验证基础表单
const { valid: baseFormValid } = await baseFormApi.validate();
// 根据模板类型验证对应的额外表单
let extraValid = true;
if (isTreeTable.value) {
const { valid: treeFormValid } = await treeFormApi.validate();
extraValid = treeFormValid;
} else if (isSubTable.value) {
const { valid: subFormValid } = await subFormApi.validate();
extraValid = subFormValid;
}
return baseFormValid && extraValid;
}
/** 设置表单值 */
function setAllFormValues(values: Record<string, any>): void {
if (!values) {
return;
}
// 记录模板类型
currentTemplateType.value = values.templateType;
// 设置基础表单值
baseFormApi.setValues(values);
// 根据模板类型设置对应的额外表单值
if (isTreeTable.value) {
treeFormApi.setValues(values);
} else if (isSubTable.value) {
subFormApi.setValues(values);
}
}
/** 监听表格数据变化 */
watch(
() => props.table,
async (val) => {
if (!val || isEmpty(val)) {
return;
}
const table = val as InfraCodegenApi.CodegenTable;
// 初始化树表的 schema
updateTreeSchema();
// 设置表单值
setAllFormValues(table);
// 获取表数据,用于主子表选择
const dataSourceConfigId = table.dataSourceConfigId;
if (dataSourceConfigId === undefined) {
return;
}
tables.value = await getCodegenTableList(dataSourceConfigId);
// 初始化子表 schema
updateSubSchema();
},
{ immediate: true },
);
/** 暴露出表单校验方法和表单值获取方法 */
defineExpose({
validate: validateAllForms,
getValues: getAllFormValues,
});
</script>
<template>
<div>
<!-- 基础表单 -->
<BaseForm />
<!-- 树表信息表单 -->
<TreeForm v-if="isTreeTable" />
<!-- 主子表信息表单 -->
<SubForm v-if="isSubTable" />
</div>
</template>

View File

@@ -0,0 +1,120 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraCodegenApi } from '#/api/infra/codegen';
import { reactive } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { createCodegenList, getSchemaTableList } from '#/api/infra/codegen';
import { $t } from '#/locales';
import {
useImportTableColumns,
useImportTableFormSchema,
} from '#/views/infra/codegen/data';
/** 定义组件事件 */
const emit = defineEmits<{
(e: 'success'): void;
}>();
const formData = reactive<InfraCodegenApi.CodegenCreateListReqVO>({
dataSourceConfigId: 0,
tableNames: [], // 已选择的表列表
});
/** 表格实例 */
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useImportTableFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useImportTableColumns(),
height: 600,
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
if (formValues.dataSourceConfigId === undefined) {
return [];
}
formData.dataSourceConfigId = formValues.dataSourceConfigId;
return await getSchemaTableList({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'name',
},
toolbarConfig: {
enabled: false,
},
checkboxConfig: {
highlight: true,
range: true,
},
pagerConfig: {
enabled: false,
},
} as VxeTableGridOptions<InfraCodegenApi.DatabaseTable>,
gridEvents: {
checkboxChange: ({
records,
}: {
records: InfraCodegenApi.DatabaseTable[];
}) => {
formData.tableNames = records.map((item) => item.name);
},
},
});
/** 模态框实例 */
const [Modal, modalApi] = useVbenModal({
title: '导入表',
class: 'w-1/2',
async onConfirm() {
modalApi.lock();
// 1.1 获取表单值
if (formData?.dataSourceConfigId === undefined) {
message.error('请选择数据源');
return;
}
// 1.2 校验是否选择了表
if (formData.tableNames.length === 0) {
message.error('请选择需要导入的表');
return;
}
// 2. 提交请求
const hideLoading = message.loading({
content: '导入中...',
duration: 0,
key: 'import_loading',
});
try {
await createCodegenList(formData);
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
hideLoading();
modalApi.unlock();
}
},
});
</script>
<template>
<Modal>
<Grid />
</Modal>
</template>

View File

@@ -0,0 +1,371 @@
<script lang="ts" setup>
// TODO @芋艿待定vben2.0 有 CodeEditor不确定官方后续会不会迁移
import type { InfraCodegenApi } from '#/api/infra/codegen';
import { h, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Copy } from '@vben/icons';
import { useClipboard } from '@vueuse/core';
import { Button, DirectoryTree, message, Tabs } from 'ant-design-vue';
import hljs from 'highlight.js/lib/core';
import java from 'highlight.js/lib/languages/java';
import javascript from 'highlight.js/lib/languages/javascript';
import sql from 'highlight.js/lib/languages/sql';
import typescript from 'highlight.js/lib/languages/typescript';
import xml from 'highlight.js/lib/languages/xml';
import { previewCodegen } from '#/api/infra/codegen';
/** 注册代码高亮语言 */
hljs.registerLanguage('java', java);
hljs.registerLanguage('xml', xml);
hljs.registerLanguage('html', xml);
hljs.registerLanguage('vue', xml);
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('sql', sql);
hljs.registerLanguage('typescript', typescript);
/** 文件树类型 */
interface FileNode {
key: string;
title: string;
parentKey: string;
isLeaf?: boolean;
children?: FileNode[];
}
/** 组件状态 */
const loading = ref(false);
const fileTree = ref<FileNode[]>([]);
const previewFiles = ref<InfraCodegenApi.CodegenPreview[]>([]);
const activeKey = ref<string>('');
/** 代码地图 */
const codeMap = ref<Map<string, string>>(new Map<string, string>());
const setCodeMap = (key: string, lang: string, code: string) => {
// 处理可能的缩进问题特别是对Java文件
const trimmedCode = code.trimStart();
// 如果已有缓存则不重新构建
if (codeMap.value.has(key)) {
return;
}
try {
const highlightedCode = hljs.highlight(trimmedCode, {
language: lang,
}).value;
codeMap.value.set(key, highlightedCode);
} catch {
codeMap.value.set(key, trimmedCode);
}
};
const removeCodeMapKey = (targetKey: any) => {
// 只有一个代码视图时不允许删除
if (codeMap.value.size === 1) {
return;
}
if (codeMap.value.has(targetKey)) {
codeMap.value.delete(targetKey);
}
};
/** 复制代码 */
const copyCode = async () => {
const { copy } = useClipboard();
const file = previewFiles.value.find(
(item) => item.filePath === activeKey.value,
);
if (file) {
await copy(file.code);
message.success('复制成功');
}
};
/** 文件节点点击事件 */
const handleNodeClick = (_: any[], e: any) => {
if (!e.node.isLeaf) return;
activeKey.value = e.node.key;
const file = previewFiles.value.find((item) => {
const list = activeKey.value.split('.');
// 特殊处理-包合并
if (list.length > 2) {
const lang = list.pop();
return item.filePath === `${list.join('/')}.${lang}`;
}
return item.filePath === activeKey.value;
});
if (!file) return;
const lang = file.filePath.split('.').pop() || '';
setCodeMap(activeKey.value, lang, file.code);
};
/** 处理文件树 */
const handleFiles = (data: InfraCodegenApi.CodegenPreview[]): FileNode[] => {
const exists: Record<string, boolean> = {};
const files: FileNode[] = [];
// 处理文件路径
for (const item of data) {
const paths = item.filePath.split('/');
let cursor = 0;
let fullPath = '';
while (cursor < paths.length) {
const path = paths[cursor] || '';
const oldFullPath = fullPath;
// 处理Java包路径特殊情况
if (path === 'java' && cursor + 1 < paths.length) {
fullPath = fullPath ? `${fullPath}/${path}` : path;
cursor++;
// 合并包路径
let packagePath = '';
while (cursor < paths.length) {
const nextPath = paths[cursor] || '';
if (
[
'controller',
'convert',
'dal',
'dataobject',
'enums',
'mysql',
'service',
'vo',
].includes(nextPath)
) {
break;
}
packagePath = packagePath ? `${packagePath}.${nextPath}` : nextPath;
cursor++;
}
if (packagePath) {
const newFullPath = `${fullPath}/${packagePath}`;
if (!exists[newFullPath]) {
exists[newFullPath] = true;
files.push({
key: newFullPath,
title: packagePath,
parentKey: oldFullPath || '/',
isLeaf: cursor === paths.length,
});
}
fullPath = newFullPath;
}
continue;
}
// 处理普通路径
fullPath = fullPath ? `${fullPath}/${path}` : path;
if (!exists[fullPath]) {
exists[fullPath] = true;
files.push({
key: fullPath,
title: path,
parentKey: oldFullPath || '/',
isLeaf: cursor === paths.length - 1,
});
}
cursor++;
}
}
/** 构建树形结构 */
const buildTree = (parentKey: string): FileNode[] => {
return files
.filter((file) => file.parentKey === parentKey)
.map((file) => ({
...file,
children: buildTree(file.key),
}));
};
return buildTree('/');
};
/** 模态框实例 */
const [Modal, modalApi] = useVbenModal({
footer: false,
fullscreen: true,
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
// 关闭时清除代码视图缓存
codeMap.value.clear();
return;
}
const row = modalApi.getData<InfraCodegenApi.CodegenTable>();
if (!row) return;
// 加载预览数据
loading.value = true;
try {
const data = await previewCodegen(row.id);
previewFiles.value = data;
// 构建代码树,并默认选中第一个文件
fileTree.value = handleFiles(data);
if (data.length > 0) {
activeKey.value = data[0]?.filePath || '';
const lang = activeKey.value.split('.').pop() || '';
const code = data[0]?.code || '';
setCodeMap(activeKey.value, lang, code);
}
} finally {
loading.value = false;
}
},
});
</script>
<template>
<Modal title="代码预览">
<div class="flex h-full" v-loading="loading">
<!-- 文件树 -->
<div
class="h-full w-1/3 overflow-auto border-r border-gray-200 pr-4 dark:border-gray-700"
>
<DirectoryTree
v-if="fileTree.length > 0"
default-expand-all
v-model:active-key="activeKey"
@select="handleNodeClick"
:tree-data="fileTree"
/>
</div>
<!-- 代码预览 -->
<div class="h-full w-2/3 overflow-auto pl-4">
<Tabs
v-model:active-key="activeKey"
hide-add
type="editable-card"
@edit="removeCodeMapKey"
>
<Tabs.TabPane
v-for="key in codeMap.keys()"
:key="key"
:tab="key.split('/').pop()"
>
<div
class="h-full rounded-md bg-gray-50 !p-0 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
>
<!-- eslint-disable-next-line vue/no-v-html -->
<code
v-html="codeMap.get(activeKey)"
class="code-highlight"
></code>
</div>
</Tabs.TabPane>
<template #rightExtra>
<Button type="primary" ghost @click="copyCode" :icon="h(Copy)">
复制代码
</Button>
</template>
</Tabs>
</div>
</div>
</Modal>
</template>
<style scoped>
/* stylelint-disable selector-class-pattern */
/* 代码高亮样式 - 支持暗黑模式 */
:deep(.code-highlight) {
display: block;
white-space: pre;
background: transparent;
}
/* 关键字 */
:deep(.hljs-keyword) {
@apply text-purple-600 dark:text-purple-400;
}
/* 字符串 */
:deep(.hljs-string) {
@apply text-green-600 dark:text-green-400;
}
/* 注释 */
:deep(.hljs-comment) {
@apply text-gray-500 dark:text-gray-400;
}
/* 函数 */
:deep(.hljs-function) {
@apply text-blue-600 dark:text-blue-400;
}
/* 数字 */
:deep(.hljs-number) {
@apply text-orange-600 dark:text-orange-400;
}
/* 类 */
:deep(.hljs-class) {
@apply text-yellow-600 dark:text-yellow-400;
}
/* 标题/函数名 */
:deep(.hljs-title) {
@apply font-bold text-blue-600 dark:text-blue-400;
}
/* 参数 */
:deep(.hljs-params) {
@apply text-gray-700 dark:text-gray-300;
}
/* 内置对象 */
:deep(.hljs-built_in) {
@apply text-teal-600 dark:text-teal-400;
}
/* HTML标签 */
:deep(.hljs-tag) {
@apply text-blue-600 dark:text-blue-400;
}
/* 属性 */
:deep(.hljs-attribute),
:deep(.hljs-attr) {
@apply text-green-600 dark:text-green-400;
}
/* 字面量 */
:deep(.hljs-literal) {
@apply text-purple-600 dark:text-purple-400;
}
/* 元信息 */
:deep(.hljs-meta) {
@apply text-gray-500 dark:text-gray-400;
}
/* 选择器标签 */
:deep(.hljs-selector-tag) {
@apply text-blue-600 dark:text-blue-400;
}
/* XML/HTML名称 */
:deep(.hljs-name) {
@apply text-blue-600 dark:text-blue-400;
}
/* 变量 */
:deep(.hljs-variable) {
@apply text-orange-600 dark:text-orange-400;
}
/* 属性 */
:deep(.hljs-property) {
@apply text-red-600 dark:text-red-400;
}
/* stylelint-enable selector-class-pattern */
</style>

View File

@@ -0,0 +1,209 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraConfigApi } from '#/api/infra/config';
import { useAccess } from '@vben/access';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'category',
label: '参数分类',
component: 'Input',
componentProps: {
placeholder: '请输入参数分类',
},
rules: 'required',
},
{
fieldName: 'name',
label: '参数名称',
component: 'Input',
componentProps: {
placeholder: '请输入参数名称',
},
rules: 'required',
},
{
fieldName: 'key',
label: '参数键名',
component: 'Input',
componentProps: {
placeholder: '请输入参数键名',
},
rules: 'required',
},
{
fieldName: 'value',
label: '参数键值',
component: 'Input',
componentProps: {
placeholder: '请输入参数键值',
},
rules: 'required',
},
{
fieldName: 'visible',
label: '是否可见',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
buttonStyle: 'solid',
optionType: 'button',
},
defaultValue: true,
rules: 'required',
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '参数名称',
component: 'Input',
componentProps: {
placeholder: '请输入参数名称',
clearable: true,
},
},
{
fieldName: 'key',
label: '参数键名',
component: 'Input',
componentProps: {
placeholder: '请输入参数键名',
clearable: true,
},
},
{
fieldName: 'type',
label: '系统内置',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_CONFIG_TYPE, 'number'),
placeholder: '请选择系统内置',
allowClear: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = InfraConfigApi.Config>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '参数主键',
minWidth: 100,
},
{
field: 'category',
title: '参数分类',
minWidth: 120,
},
{
field: 'name',
title: '参数名称',
minWidth: 200,
},
{
field: 'key',
title: '参数键名',
minWidth: 200,
},
{
field: 'value',
title: '参数键值',
minWidth: 150,
},
{
field: 'visible',
title: '是否可见',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'type',
title: '系统内置',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_CONFIG_TYPE },
},
},
{
field: 'remark',
title: '备注',
minWidth: 150,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 130,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '参数',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['infra:config:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['infra:config:delete']),
},
],
},
},
];
}

View File

@@ -0,0 +1,135 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { InfraConfigApi } from '#/api/infra/config';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteConfig, exportConfig, getConfigPage } from '#/api/infra/config';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 导出表格 */
async function onExport() {
const data = await exportConfig(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '参数配置.xls', source: data });
}
/** 创建参数 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 编辑参数 */
function onEdit(row: InfraConfigApi.Config) {
formModalApi.setData(row).open();
}
/** 删除参数 */
async function onDelete(row: InfraConfigApi.Config) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteConfig(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} catch {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<InfraConfigApi.Config>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getConfigPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<InfraConfigApi.Config>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="参数列表">
<template #toolbar-tools>
<Button
type="primary"
@click="onCreate"
v-access:code="['infra:config:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['参数']) }}
</Button>
<Button
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:config:export']"
>
<Download class="size-5" />
{{ $t('ui.actionTitle.export') }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
import type { InfraConfigApi } from '#/api/infra/config';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createConfig, getConfig, updateConfig } from '#/api/infra/config';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<InfraConfigApi.Config>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['参数'])
: $t('ui.actionTitle.create', ['参数']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as InfraConfigApi.Config;
try {
await (formData.value?.id ? updateConfig(data) : createConfig(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<InfraConfigApi.Config>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getConfig(data.id as number);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,119 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraDataSourceConfigApi } from '#/api/infra/data-source-config';
import { useAccess } from '@vben/access';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '数据源名称',
component: 'Input',
componentProps: {
placeholder: '请输入数据源名称',
},
rules: 'required',
},
{
fieldName: 'url',
label: '数据源连接',
component: 'Input',
componentProps: {
placeholder: '请输入数据源连接',
},
rules: 'required',
},
{
fieldName: 'username',
label: '用户名',
component: 'Input',
componentProps: {
placeholder: '请输入用户名',
},
rules: 'required',
},
{
fieldName: 'password',
label: '密码',
component: 'Input',
componentProps: {
placeholder: '请输入密码',
type: 'password',
},
rules: 'required',
},
];
}
/** 列表的字段 */
export function useGridColumns<T = InfraDataSourceConfigApi.DataSourceConfig>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '主键编号',
minWidth: 100,
},
{
field: 'name',
title: '数据源名称',
minWidth: 150,
},
{
field: 'url',
title: '数据源连接',
minWidth: 300,
},
{
field: 'username',
title: '用户名',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 130,
align: 'center',
fixed: 'right',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '数据源',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['infra:data-source-config:update']),
disabled: (row: any) => row.id === 0,
},
{
code: 'delete',
show: hasAccessByCodes(['infra:data-source-config:delete']),
disabled: (row: any) => row.id === 0,
},
],
},
},
];
}

View File

@@ -0,0 +1,124 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { InfraDataSourceConfigApi } from '#/api/infra/data-source-config';
import { onMounted } from 'vue';
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 {
deleteDataSourceConfig,
getDataSourceConfigList,
} from '#/api/infra/data-source-config';
import { $t } from '#/locales';
import { useGridColumns } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 创建数据源 */
function onCreate() {
formModalApi.setData(null).open();
}
/** 编辑数据源 */
function onEdit(row: InfraDataSourceConfigApi.DataSourceConfig) {
formModalApi.setData(row).open();
}
/** 删除数据源 */
async function onDelete(row: InfraDataSourceConfigApi.DataSourceConfig) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDataSourceConfig(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
await handleLoadData();
} catch {
hideLoading();
}
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<InfraDataSourceConfigApi.DataSourceConfig>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
keepSource: true,
rowConfig: {
keyField: 'id',
},
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: getDataSourceConfigList,
},
},
} as VxeTableGridOptions<InfraDataSourceConfigApi.DataSourceConfig>,
});
/** 加载数据 */
async function handleLoadData() {
await gridApi.query();
}
/** 刷新表格 */
async function onRefresh() {
await handleLoadData();
}
/** 初始化 */
onMounted(() => {
handleLoadData();
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="数据源列表">
<template #toolbar-tools>
<Button
type="primary"
@click="onCreate"
v-access:code="['infra:data-source-config:create']"
>
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', ['数据源']) }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,89 @@
<script lang="ts" setup>
import type { InfraDataSourceConfigApi } from '#/api/infra/data-source-config';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createDataSourceConfig,
getDataSourceConfig,
updateDataSourceConfig,
} from '#/api/infra/data-source-config';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<InfraDataSourceConfigApi.DataSourceConfig>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['数据源'])
: $t('ui.actionTitle.create', ['数据源']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data =
(await formApi.getValues()) as InfraDataSourceConfigApi.DataSourceConfig;
try {
await (formData.value?.id
? updateDataSourceConfig(data)
: createDataSourceConfig(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<InfraDataSourceConfigApi.DataSourceConfig>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getDataSourceConfig(data.id as number);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,176 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo01ContactApi } from '#/api/infra/demo/demo01';
import { useAccess } from '@vben/access';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
const { hasAccessByCodes } = useAccess();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '名字',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入名字',
},
},
{
fieldName: 'sex',
label: '性别',
rules: 'required',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
},
{
fieldName: 'birthday',
label: '出生年',
rules: 'required',
component: 'DatePicker',
componentProps: {
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
},
},
{
fieldName: 'description',
label: '简介',
rules: 'required',
component: 'RichTextarea',
},
{
fieldName: 'avatar',
label: '头像',
component: 'ImageUpload',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '名字',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入名字',
},
},
{
fieldName: 'sex',
label: '性别',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'),
placeholder: '请选择性别',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(
onActionClick?: OnActionClickFn<Demo01ContactApi.Demo01Contact>,
): VxeTableGridOptions<Demo01ContactApi.Demo01Contact>['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 120,
},
{
field: 'name',
title: '名字',
minWidth: 120,
},
{
field: 'sex',
title: '性别',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_USER_SEX },
},
},
{
field: 'birthday',
title: '出生年',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'description',
title: '简介',
minWidth: 120,
},
{
field: 'avatar',
title: '头像',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'operation',
title: '操作',
minWidth: 200,
align: 'center',
fixed: 'right',
// TODO @puhui999headerAlign 要使用 headerAlign: 'center' 么?看着现在分成了 align 和 headerAlign 两种
headerAlign: 'center',
showOverflow: false,
cellRender: {
attrs: {
nameField: 'id',
nameTitle: '示例联系人',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'edit',
show: hasAccessByCodes(['infra:demo01-contact:update']),
},
{
code: 'delete',
show: hasAccessByCodes(['infra:demo01-contact:delete']),
},
],
},
},
];
}

View File

@@ -0,0 +1,145 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { Demo01ContactApi } from '#/api/infra/demo/demo01';
import { h } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Download, Plus } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDemo01Contact,
exportDemo01Contact,
getDemo01ContactPage,
} from '#/api/infra/demo/demo01';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建示例联系人 */
function onCreate() {
formModalApi.setData({}).open();
}
/** 编辑示例联系人 */
function onEdit(row: Demo01ContactApi.Demo01Contact) {
formModalApi.setData(row).open();
}
/** 删除示例联系人 */
async function onDelete(row: Demo01ContactApi.Demo01Contact) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
key: 'action_process_msg',
});
try {
await deleteDemo01Contact(row.id as number);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
onRefresh();
} catch {
hideLoading();
}
}
/** 导出表格 */
async function onExport() {
const data = await exportDemo01Contact(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '示例联系人.xls', source: data });
}
/** 表格操作按钮的回调函数 */
function onActionClick({
code,
row,
}: OnActionClickParams<Demo01ContactApi.Demo01Contact>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(onActionClick),
height: 'auto',
pagerConfig: {
enabled: true,
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDemo01ContactPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<Demo01ContactApi.Demo01Contact>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="示例联系人列表">
<template #toolbar-tools>
<Button
:icon="h(Plus)"
type="primary"
@click="onCreate"
v-access:code="['infra:demo01-contact:create']"
>
{{ $t('ui.actionTitle.create', ['示例联系人']) }}
</Button>
<Button
:icon="h(Download)"
type="primary"
class="ml-2"
@click="onExport"
v-access:code="['infra:demo01-contact:export']"
>
{{ $t('ui.actionTitle.export') }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,95 @@
<script lang="ts" setup>
import type { Demo01ContactApi } from '#/api/infra/demo/demo01';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createDemo01Contact,
getDemo01Contact,
updateDemo01Contact,
} from '#/api/infra/demo/demo01';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<Demo01ContactApi.Demo01Contact>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['示例联系人'])
: $t('ui.actionTitle.create', ['示例联系人']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as Demo01ContactApi.Demo01Contact;
try {
await (formData.value?.id
? updateDemo01Contact(data)
: createDemo01Contact(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success({
content: $t('ui.actionMessage.operationSuccess'),
key: 'action_process_msg',
});
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
let data = modalApi.getData<Demo01ContactApi.Demo01Contact>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo01Contact(data.id);
} finally {
modalApi.unlock();
}
}
// 设置到 values
formData.value = data;
await formApi.setValues(formData.value);
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

Some files were not shown because too many files have changed in this diff Show More