Initial commit: OneOS frontend based on yudao-ui-admin-vben
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled

This commit is contained in:
k kfluous
2026-03-11 22:18:23 +08:00
commit 2650d1cdf0
5166 changed files with 641242 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,172 @@
<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 '#/adapter/tdesign';
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,215 @@
<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 '#/adapter/tdesign';
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'));
// 重置成功后跳转到首页
await 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,210 @@
<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 { getUrlValue } from '@vben/utils';
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);
}
}
/** 组件挂载时获取租户信息 */
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,101 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import type { VbenFormSchema } from '#/adapter/form';
import type { SystemUserProfileApi } from '#/api/system/user/profile';
import { computed, ref, watch } from 'vue';
import { ProfileBaseSetting, z } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { message } from '#/adapter/tdesign';
import { updateUserProfile } from '#/api/system/user/profile';
import { $t } from '#/locales';
const props = defineProps<{
profile?: SystemUserProfileApi.UserProfileRespVO;
}>();
const emit = defineEmits<{
(e: 'success'): void;
}>();
const profileBaseSettingRef = ref();
const formSchema = computed((): VbenFormSchema[] => {
return [
{
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(),
},
];
});
async function handleSubmit(values: Recordable<any>) {
try {
profileBaseSettingRef.value.getFormApi().setLoading(true);
// 提交表单
await updateUserProfile(values as SystemUserProfileApi.UpdateProfileReqVO);
// 关闭并提示
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} catch (error) {
console.error(error);
} finally {
profileBaseSettingRef.value.getFormApi().setLoading(false);
}
}
/** 监听 profile 变化 */
watch(
() => props.profile,
(newProfile) => {
if (newProfile) {
profileBaseSettingRef.value.getFormApi().setValues(newProfile);
}
},
{ immediate: true },
);
</script>
<template>
<ProfileBaseSetting
ref="profileBaseSettingRef"
:form-schema="formSchema"
@submit="handleSubmit"
/>
</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 'tdesign-vue-next';
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 { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { $t } from '@vben/locales';
import { useVbenForm, z } from '#/adapter/form';
import { message } from '#/adapter/tdesign';
import { updateUserProfile } from '#/api/system/user/profile';
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-4 md:w-full lg:w-1/2 2xl:w-2/5">
<Form />
</div>
</template>

View File

@@ -0,0 +1,148 @@
<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 'tdesign-vue-next';
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 && profile.posts.length > 0
? 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,93 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import { $t } from '@vben/locales';
import { useVbenForm, z } from '#/adapter/form';
import { message } from '#/adapter/tdesign';
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-4 md:w-full lg:w-1/2 2xl:w-2/5">
<Form />
</div>
</template>

View File

@@ -0,0 +1,208 @@
<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 { DICT_TYPE, SystemUserSocialTypeEnum } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { getUrlValue } from '@vben/utils';
import { Button, Card, Image } from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
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';
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)} variant="text">
解绑
</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);
}
/** 初始化 */
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-1 text-sm 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"
variant="text"
@click="onBind(item)"
>
{{ item.socialUser ? '已绑定' : '绑定' }}
</Button>
</div>
</div>
</Card>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileNotificationSetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '其他用户的消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'systemMessage',
label: '系统消息',
description: '系统消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'todoTask',
label: '待办任务',
description: '待办任务将以站内信的形式通知',
},
];
});
</script>
<template>
<ProfileNotificationSetting :form-schema="formSchema" />
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import type { VbenFormSchema } from '#/adapter/form';
import { computed } from 'vue';
import { ProfilePasswordSetting, z } from '@vben/common-ui';
import { message } from '#/adapter/tdesign';
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'oldPassword',
label: '旧密码',
component: 'VbenInputPassword',
componentProps: {
placeholder: '请输入旧密码',
},
},
{
fieldName: 'newPassword',
label: '新密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请输入新密码',
},
},
{
fieldName: 'confirmPassword',
label: '确认密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请再次输入新密码',
},
dependencies: {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',
});
},
triggerFields: ['newPassword'],
},
},
];
});
function handleSubmit() {
message.success('密码修改成功');
}
</script>
<template>
<ProfilePasswordSetting
class="w-1/3"
:form-schema="formSchema"
@submit="handleSubmit"
/>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileSecuritySetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '当前密码强度:强',
},
{
value: true,
fieldName: 'securityPhone',
label: '密保手机',
description: '已绑定手机138****8293',
},
{
value: true,
fieldName: 'securityQuestion',
label: '密保问题',
description: '未设置密保问题,密保问题可有效保护账户安全',
},
{
value: true,
fieldName: 'securityEmail',
label: '备用邮箱',
description: '已绑定邮箱ant***sign.com',
},
{
value: false,
fieldName: 'securityMfa',
label: 'MFA 设备',
description: '未绑定 MFA 设备,绑定后,可以进行二次确认',
},
];
});
</script>
<template>
<ProfileSecuritySetting :form-schema="formSchema" />
</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 },
].toSorted((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,266 @@
<script lang="ts" setup>
import type {
WorkbenchProjectItem,
WorkbenchQuickNavItem,
WorkbenchTodoItem,
WorkbenchTrendItem,
} from '@vben/common-ui';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import {
AnalysisChartCard,
WorkbenchHeader,
WorkbenchProject,
WorkbenchQuickNav,
WorkbenchTodo,
WorkbenchTrends,
} from '@vben/common-ui';
import { preferences } from '@vben/preferences';
import { useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
import AnalyticsVisitsSource from '../analytics/analytics-visits-source.vue';
const userStore = useUserStore();
// 这是一个示例数据,实际项目中需要根据实际情况进行调整
// url 也可以是内部路由,在 navTo 方法中识别处理,进行内部跳转
// 例如url: /dashboard/workspace
const projectItems: WorkbenchProjectItem[] = [
{
color: '',
content: '不要等待机会,而要创造机会。',
date: '2021-04-01',
group: '开源组',
icon: 'carbon:logo-github',
title: 'Github',
url: 'https://github.com',
},
{
color: '#3fb27f',
content: '现在的你决定将来的你。',
date: '2021-04-01',
group: '算法组',
icon: 'ion:logo-vue',
title: 'Vue',
url: 'https://vuejs.org',
},
{
color: '#e18525',
content: '没有什么才能比努力更重要。',
date: '2021-04-01',
group: '上班摸鱼',
icon: 'ion:logo-html5',
title: 'Html5',
url: 'https://developer.mozilla.org/zh-CN/docs/Web/HTML',
},
{
color: '#bf0c2c',
content: '热情和欲望可以突破一切难关。',
date: '2021-04-01',
group: 'UI',
icon: 'ion:logo-angular',
title: 'Angular',
url: 'https://angular.io',
},
{
color: '#00d8ff',
content: '健康的身体是实现目标的基石。',
date: '2021-04-01',
group: '技术牛',
icon: 'bx:bxl-react',
title: 'React',
url: 'https://reactjs.org',
},
{
color: '#EBD94E',
content: '路是走出来的,而不是空想出来的。',
date: '2021-04-01',
group: '架构组',
icon: 'ion:logo-javascript',
title: 'Js',
url: 'https://developer.mozilla.org/zh-CN/docs/Web/JavaScript',
},
];
// 同样,这里的 url 也可以使用以 http 开头的外部链接
const quickNavItems: WorkbenchQuickNavItem[] = [
{
color: '#1fdaca',
icon: 'ion:home-outline',
title: '首页',
url: '/',
},
{
color: '#bf0c2c',
icon: 'ion:grid-outline',
title: '仪表盘',
url: '/dashboard',
},
{
color: '#e18525',
icon: 'ion:layers-outline',
title: '组件',
url: '/demos/features/icons',
},
{
color: '#3fb27f',
icon: 'ion:settings-outline',
title: '系统管理',
url: '/demos/features/login-expired', // 这里的 URL 是示例,实际项目中需要根据实际情况进行调整
},
{
color: '#4daf1bc9',
icon: 'ion:key-outline',
title: '权限管理',
url: '/demos/access/page-control',
},
{
color: '#00d8ff',
icon: 'ion:bar-chart-outline',
title: '图表',
url: '/analytics',
},
];
const todoItems = ref<WorkbenchTodoItem[]>([
{
completed: false,
content: `审查最近提交到Git仓库的前端代码确保代码质量和规范。`,
date: '2024-07-30 11:00:00',
title: '审查前端代码提交',
},
{
completed: true,
content: `检查并优化系统性能降低CPU使用率。`,
date: '2024-07-30 11:00:00',
title: '系统性能优化',
},
{
completed: false,
content: `进行系统安全检查,确保没有安全漏洞或未授权的访问。 `,
date: '2024-07-30 11:00:00',
title: '安全检查',
},
{
completed: false,
content: `更新项目中的所有npm依赖包确保使用最新版本。`,
date: '2024-07-30 11:00:00',
title: '更新项目依赖',
},
{
completed: false,
content: `修复用户报告的页面UI显示问题确保在不同浏览器中显示一致。 `,
date: '2024-07-30 11:00:00',
title: '修复UI显示问题',
},
]);
const trendItems: WorkbenchTrendItem[] = [
{
avatar: 'svg:avatar-1',
content: `在 <a>开源组</a> 创建了项目 <a>Vue</a>`,
date: '刚刚',
title: '威廉',
},
{
avatar: 'svg:avatar-2',
content: `关注了 <a>威廉</a> `,
date: '1个小时前',
title: '艾文',
},
{
avatar: 'svg:avatar-3',
content: `发布了 <a>个人动态</a> `,
date: '1天前',
title: '克里斯',
},
{
avatar: 'svg:avatar-4',
content: `发表文章 <a>如何编写一个Vite插件</a> `,
date: '2天前',
title: 'Vben',
},
{
avatar: 'svg:avatar-1',
content: `回复了 <a>杰克</a> 的问题 <a>如何进行项目优化?</a>`,
date: '3天前',
title: '皮特',
},
{
avatar: 'svg:avatar-2',
content: `关闭了问题 <a>如何运行项目</a> `,
date: '1周前',
title: '杰克',
},
{
avatar: 'svg:avatar-3',
content: `发布了 <a>个人动态</a> `,
date: '1周前',
title: '威廉',
},
{
avatar: 'svg:avatar-4',
content: `推送了代码到 <a>Github</a>`,
date: '2021-04-01 20:00',
title: '威廉',
},
{
avatar: 'svg:avatar-4',
content: `发表文章 <a>如何编写使用 Admin Vben</a> `,
date: '2021-03-01 20:00',
title: 'Vben',
},
];
const router = useRouter();
// 这是一个示例方法,实际项目中需要根据实际情况进行调整
// This is a sample method, adjust according to the actual project requirements
function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
if (nav.url?.startsWith('http')) {
openWindow(nav.url);
return;
}
if (nav.url?.startsWith('/')) {
router.push(nav.url).catch((error) => {
console.error('Navigation failed:', error);
});
} else {
console.warn(`Unknown URL for navigation item: ${nav.title} -> ${nav.url}`);
}
}
</script>
<template>
<div class="p-5">
<WorkbenchHeader
:avatar="userStore.userInfo?.avatar || preferences.app.defaultAvatar"
>
<template #title>
早安, {{ userStore.userInfo?.realName }}, 开始您一天的工作吧
</template>
<template #description> 今日晴20 - 32 </template>
</WorkbenchHeader>
<div class="mt-5 flex flex-col lg:flex-row">
<div class="mr-4 w-full lg:w-3/5">
<WorkbenchProject :items="projectItems" title="项目" @click="navTo" />
<WorkbenchTrends :items="trendItems" class="mt-5" title="最新动态" />
</div>
<div class="w-full lg:w-2/5">
<WorkbenchQuickNav
:items="quickNavItems"
class="mt-5 lg:mt-0"
title="快捷导航"
@click="navTo"
/>
<WorkbenchTodo :items="todoItems" class="mt-5" title="待办事项" />
<AnalysisChartCard class="mt-5" title="访问来源">
<AnalyticsVisitsSource />
</AnalysisChartCard>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,273 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { JsonViewer } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { formatDateTime } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
import { getRangePickerDefaultProps } from '#/utils';
/** 列表的搜索表单 */
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(): 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: ({ cellValue }) => `${cellValue} 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 },
},
},
{
title: '操作',
width: 80,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 详情页的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'id',
label: '日志编号',
},
{
field: 'traceId',
label: '链路追踪',
},
{
field: 'applicationName',
label: '应用名',
},
{
field: 'userId',
label: '用户Id',
},
{
field: 'userType',
label: '用户类型',
render: (val) => {
return h(DictTag, {
type: DICT_TYPE.USER_TYPE,
value: val,
});
},
},
{
field: 'userIp',
label: '用户 IP',
},
{
field: 'userAgent',
label: '用户 UA',
},
{
field: 'requestMethod',
label: '请求信息',
render: (val, data) => {
if (val && data?.requestUrl) {
return `${val} ${data.requestUrl}`;
}
return '';
},
},
{
field: 'requestParams',
label: '请求参数',
render: (val) => {
if (val) {
return h(JsonViewer, {
value: JSON.parse(val),
previewMode: true,
});
}
return '';
},
},
{
field: 'responseBody',
label: '请求结果',
},
{
label: '请求时间',
field: 'beginTime',
render: (val, data) => {
if (val && data?.endTime) {
return `${formatDateTime(val)} ~ ${formatDateTime(data.endTime)}`;
}
return '';
},
},
{
label: '请求耗时',
field: 'duration',
render: (val) => {
return val ? `${val} ms` : '';
},
},
{
label: '操作结果',
field: 'resultCode',
render: (val, data) => {
if (val === 0) {
return '正常';
} else if (val > 0 && data?.resultMsg) {
return `失败 | ${val} | ${data.resultMsg}`;
}
return '';
},
},
{
field: 'operateModule',
label: '操作模块',
},
{
field: 'operateName',
label: '操作名',
},
{
field: 'operateType',
label: '操作类型',
render: (val) => {
return h(DictTag, {
type: DICT_TYPE.INFRA_OPERATE_TYPE,
value: val,
});
},
},
];
}

View File

@@ -0,0 +1,107 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraApiAccessLogApi } from '#/api/infra/api-access-log';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
exportApiAccessLog,
getApiAccessLogPage,
} from '#/api/infra/api-access-log';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue';
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportApiAccessLog(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: 'API 访问日志.xls', source: data });
}
/** 查看 API 访问日志详情 */
function handleDetail(row: InfraApiAccessLogApi.ApiAccessLog) {
detailModalApi.setData(row).open();
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getApiAccessLogPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
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="handleRefresh" />
<Grid table-title="API 访问日志列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['infra:api-access-log:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.detail'),
type: 'primary',
variant: 'text',
icon: ACTION_ICON.VIEW,
auth: ['infra:api-access-log:query'],
onClick: handleDetail.bind(null, row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,50 @@
<script lang="ts" setup>
import type { InfraApiAccessLogApi } from '#/api/infra/api-access-log';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useDescription } from '#/components/description';
import { useDetailSchema } from '../data';
const formData = ref<InfraApiAccessLogApi.ApiAccessLog>();
const [Descriptions] = useDescription({
bordered: true,
column: 1,
schema: useDetailSchema(),
});
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 :data="formData" />
</Modal>
</template>

View File

@@ -0,0 +1,249 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { JsonViewer } from '@vben/common-ui';
import { DICT_TYPE, InfraApiErrorLogProcessStatusEnum } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { formatDateTime } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
import { getRangePickerDefaultProps } from '#/utils';
/** 列表的搜索表单 */
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(): 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 },
},
},
{
title: '操作',
minWidth: 220,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 详情页的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'id',
label: '日志编号',
},
{
field: 'traceId',
label: '链路追踪',
},
{
field: 'applicationName',
label: '应用名',
},
{
field: 'userId',
label: '用户Id',
},
{
field: 'userType',
label: '用户类型',
render: (val) => {
return h(DictTag, {
type: DICT_TYPE.USER_TYPE,
value: val,
});
},
},
{
field: 'userIp',
label: '用户 IP',
},
{
field: 'userAgent',
label: '用户 UA',
},
{
field: 'requestMethod',
label: '请求信息',
render: (val, data) => {
if (val && data?.requestUrl) {
return `${val} ${data.requestUrl}`;
}
return '';
},
},
{
field: 'requestParams',
label: '请求参数',
render: (val) => {
if (val) {
return h(JsonViewer, {
value: JSON.parse(val),
previewMode: true,
});
}
return '';
},
},
{
field: 'exceptionTime',
label: '异常时间',
render: (val) => {
return formatDateTime(val) as string;
},
},
{
field: 'exceptionName',
label: '异常名',
},
{
field: 'exceptionStackTrace',
label: '异常堆栈',
show: (val) => !val,
render: (val) => {
if (val) {
return h('textarea', {
value: val,
style:
'width: 100%; min-height: 200px; max-height: 400px; resize: vertical;',
readonly: true,
});
}
return '';
},
},
{
field: 'processStatus',
label: '处理状态',
render: (val) => {
return h(DictTag, {
type: DICT_TYPE.INFRA_API_ERROR_LOG_PROCESS_STATUS,
value: val,
});
},
},
{
field: 'processUserId',
label: '处理人',
show: (val) => !val,
},
{
field: 'processTime',
label: '处理时间',
show: (val) => !val,
render: (val) => {
return formatDateTime(val) as string;
},
},
];
}

View File

@@ -0,0 +1,156 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraApiErrorLogApi } from '#/api/infra/api-error-log';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { InfraApiErrorLogProcessStatusEnum } from '@vben/constants';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from '#/adapter/tdesign';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
exportApiErrorLog,
getApiErrorLogPage,
updateApiErrorLogStatus,
} from '#/api/infra/api-error-log';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue';
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportApiErrorLog(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: 'API 错误日志.xls', source: data });
}
/** 查看 API 错误日志详情 */
function handleDetail(row: InfraApiErrorLogApi.ApiErrorLog) {
detailModalApi.setData(row).open();
}
/** 处理已处理 / 已忽略的操作 */
async function handleProcess(id: number, processStatus: number) {
await confirm({
content: `确认标记为${InfraApiErrorLogProcessStatusEnum.DONE ? '已处理' : '已忽略'}?`,
});
const hideLoading = message.loading({
content: '正在处理中...',
duration: 0,
});
try {
await updateApiErrorLogStatus(id, processStatus);
message.success($t('ui.actionMessage.operationSuccess'));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getApiErrorLogPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
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="handleRefresh" />
<Grid table-title="API 错误日志列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['infra:api-error-log:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.detail'),
type: 'primary',
variant: 'text',
icon: ACTION_ICON.VIEW,
auth: ['infra:api-error-log:query'],
onClick: handleDetail.bind(null, row),
},
{
label: '已处理',
type: 'primary',
variant: 'text',
icon: ACTION_ICON.ADD,
auth: ['infra:api-error-log:update-status'],
ifShow:
row.processStatus === InfraApiErrorLogProcessStatusEnum.INIT,
onClick: handleProcess.bind(
null,
row.id,
InfraApiErrorLogProcessStatusEnum.DONE,
),
},
{
label: '已忽略',
type: 'danger',
variant: 'text',
icon: ACTION_ICON.DELETE,
auth: ['infra:api-error-log:update-status'],
ifShow:
row.processStatus === InfraApiErrorLogProcessStatusEnum.INIT,
onClick: handleProcess.bind(
null,
row.id,
InfraApiErrorLogProcessStatusEnum.IGNORE,
),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,50 @@
<script lang="ts" setup>
import type { InfraApiErrorLogApi } from '#/api/infra/api-error-log';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useDescription } from '#/components/description';
import { useDetailSchema } from '../data';
const formData = ref<InfraApiErrorLogApi.ApiErrorLog>();
const [Descriptions] = useDescription({
bordered: true,
column: 1,
schema: useDetailSchema(),
});
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 :data="formData" />
</Modal>
</template>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
</script>
<template>
<Page> 待完成 </Page>
</template>

View File

@@ -0,0 +1,552 @@
import type { Recordable } from '@vben/types';
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraCodegenApi } from '#/api/infra/codegen';
import type { SystemMenuApi } from '#/api/system/menu';
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
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 { getRangePickerDefaultProps } from '#/utils';
/** 导入数据库表的表单 */
export function useImportTableFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'dataSourceConfigId',
label: '数据源',
component: 'ApiSelect',
componentProps: {
api: getDataSourceConfigList,
labelField: 'name',
valueField: '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(
getDataSourceConfigName?: (dataSourceConfigId: number) => string | undefined,
): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'dataSourceConfigId',
title: '数据源',
minWidth: 120,
formatter: ({ cellValue }) => getDataSourceConfigName?.(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',
},
{
title: '操作',
width: 280,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 代码生成表格列定义 */
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,168 @@
<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, Steps } from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
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>>();
/** 获取详情数据 */
async function getDetail() {
const id = route.query.id as any;
if (!id) {
return;
}
loading.value = true;
try {
formData.value = await getCodegenTable(id);
} finally {
loading.value = false;
}
}
/** 提交表单 */
async function submitForm() {
// 表单验证
const basicInfoValid = await basicInfoRef.value?.validate();
if (!basicInfoValid) {
message.warning('保存失败,原因:基本信息表单校验失败请检查!!!');
return;
}
const generateInfoValid = await generateInfoRef.value?.validate();
if (!generateInfoValid) {
message.warning('保存失败,原因:生成信息表单校验失败请检查!!!');
return;
}
// 提交表单
const hideLoading = message.loading({
content: $t('ui.actionMessage.updating'),
duration: 0,
});
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 {
message.close(hideLoading);
}
}
/** 返回列表 */
const tabs = useTabs();
function close() {
tabs.closeCurrentTab();
router.push({ name: 'InfraCodegen' });
}
/** 下一步 */
function nextStep() {
currentStep.value += 1;
}
/** 上一步 */
function 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-card p-4">
<Steps
type="navigation"
v-model:current="currentStep"
class="mb-8 rounded shadow-sm"
>
<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 :disabled="currentStep === 0" @click="prevStep">上一步</Button>
<Button :disabled="currentStep === steps.length - 1" @click="nextStep">
下一步
</Button>
<Button theme="primary" :loading="loading" @click="submitForm">
保存
</Button>
</div>
</div>
</Page>
</template>

View File

@@ -0,0 +1,286 @@
<script lang="ts" setup>
import type { 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 { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message } from '#/adapter/tdesign';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteCodegenTable,
deleteCodegenTableList,
downloadCodegen,
getCodegenTablePage,
syncCodegenFromDB,
} from '#/api/infra/codegen';
import { getDataSourceConfigList } from '#/api/infra/data-source-config';
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 handleRefresh() {
gridApi.query();
}
/** 导入表格 */
function handleImport() {
importModalApi.open();
}
/** 预览代码 */
function handlePreview(row: InfraCodegenApi.CodegenTable) {
previewModalApi.setData(row).open();
}
/** 编辑表格 */
function handleEdit(row: InfraCodegenApi.CodegenTable) {
router.push({ name: 'InfraCodegenEdit', query: { id: row.id } });
}
/** 删除代码生成配置 */
async function handleDelete(row: InfraCodegenApi.CodegenTable) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.tableName]),
duration: 0,
});
try {
await deleteCodegenTable(row.id);
message.success($t('ui.actionMessage.deleteSuccess', [row.tableName]));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
/** 批量删除代码生成配置 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading({
content: $t('ui.actionMessage.deletingBatch'),
duration: 0,
});
try {
await deleteCodegenTableList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: InfraCodegenApi.CodegenTable[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
/** 同步数据库 */
async function handleSync(row: InfraCodegenApi.CodegenTable) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.updating', [row.tableName]),
duration: 0,
});
try {
await syncCodegenFromDB(row.id);
message.success($t('ui.actionMessage.updateSuccess', [row.tableName]));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
/** 生成代码 */
async function handleGenerate(row: InfraCodegenApi.CodegenTable) {
const hideLoading = message.loading({
content: '正在生成代码...',
duration: 0,
});
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 {
message.close(hideLoading);
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(getDataSourceConfigName),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCodegenTablePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<InfraCodegenApi.CodegenTable>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
/** 获取数据源配置列表 */
// TODO @芋艿:这种场景的最佳实践;
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="handleRefresh" />
<PreviewModal />
<Grid table-title="代码生成列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.import'),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['infra:codegen:create'],
onClick: handleImport,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'danger',
icon: ACTION_ICON.DELETE,
disabled: isEmpty(checkedIds),
auth: ['infra:codegen:delete'],
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '预览',
type: 'primary',
variant: 'text',
icon: ACTION_ICON.VIEW,
auth: ['infra:codegen:preview'],
onClick: handlePreview.bind(null, row),
},
{
label: '生成代码',
type: 'primary',
variant: 'text',
icon: ACTION_ICON.DOWNLOAD,
auth: ['infra:codegen:download'],
onClick: handleGenerate.bind(null, row),
},
]"
:drop-down-actions="[
{
label: $t('common.edit'),
type: 'primary',
variant: 'text',
auth: ['infra:codegen:update'],
onClick: handleEdit.bind(null, row),
},
{
label: '同步',
type: 'primary',
variant: 'text',
auth: ['infra:codegen:update'],
onClick: handleSync.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
variant: 'text',
auth: ['infra:codegen:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.tableName]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</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,156 @@
<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 'tdesign-vue-next';
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',
isHover: true,
},
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="row.columnComment" />
</template>
<!-- Java 类型 -->
<template #javaType="{ row, column }">
<Select v-model="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="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="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="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="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="row.example" />
</template>
</Grid>
</template>

View File

@@ -0,0 +1,176 @@
<script lang="ts" setup>
import type { InfraCodegenApi } from '#/api/infra/codegen';
import { computed, ref, watch } from 'vue';
import { InfraCodegenTemplateTypeEnum } from '@vben/constants';
import { isEmpty } from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import { getCodegenTableList } from '#/api/infra/codegen';
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),
});
// 树表信息回显
treeFormApi.setValues(props.table as any);
}
/** 更新主子表信息表单 schema */
function updateSubSchema(): void {
subFormApi.setState({
schema: useGenerationInfoSubTableFormSchema(props.columns, tables.value),
});
// 主子表信息回显
subFormApi.setValues(props.table as any);
}
/** 获取合并的表单值 */
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,130 @@
<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 '#/adapter/tdesign';
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: [], // 已选择的表列表
});
/** 处理选择变化 */
function handleCheckboxChange({
records,
}: {
records: InfraCodegenApi.DatabaseTable[];
}) {
formData.tableNames = records.map((item) => item.name);
}
/** 表格实例 */
const [Grid, gridApi] = 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',
isHover: true,
},
toolbarConfig: {
enabled: false,
},
checkboxConfig: {
highlight: true,
range: true,
},
pagerConfig: {
enabled: false,
},
} as VxeTableGridOptions<InfraCodegenApi.DatabaseTable>,
gridEvents: {
checkboxChange: handleCheckboxChange,
checkboxAll: handleCheckboxChange,
},
});
/** 模态框实例 */
const [Modal, modalApi] = useVbenModal({
title: '导入表',
class: 'w-1/2',
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
// 关闭时清空选择状态
formData.tableNames = [];
await gridApi.grid?.clearCheckboxRow();
}
},
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,
});
try {
await createCodegenList(formData);
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
message.close(hideLoading);
modalApi.unlock();
}
},
});
</script>
<template>
<Modal>
<Grid />
</Modal>
</template>

View File

@@ -0,0 +1,262 @@
<script lang="ts" setup>
import type { InfraCodegenApi } from '#/api/infra/codegen';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { CodeEditor } from '@vben/plugins/code-editor';
import { useClipboard } from '@vueuse/core';
import { Button, Tabs } from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import { previewCodegen } from '#/api/infra/codegen';
/** 文件树类型 */
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>());
function setCodeMap(key: string, code: string) {
// 处理可能的缩进问题特别是对Java文件
const trimmedCode = code.trimStart();
// 如果已有缓存则不重新构建
if (codeMap.value.has(key)) {
return;
}
codeMap.value.set(key, trimmedCode);
}
/** 删除代码地图 */
function removeCodeMapKey(targetKey: any) {
// 只有一个代码视图时不允许删除
if (codeMap.value.size === 1) {
return;
}
if (codeMap.value.has(targetKey)) {
codeMap.value.delete(targetKey);
}
}
/** 复制代码 */
async function copyCode() {
const { copy } = useClipboard();
const file = previewFiles.value.find(
(item) => item.filePath === activeKey.value,
);
if (file) {
await copy(file.code);
message.success('复制成功');
}
}
/** 文件节点点击事件 */
function 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;
}
setCodeMap(activeKey.value, file.code);
}
/** 处理文件树 */
function 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++;
}
}
/** 构建树形结构 */
function 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 code = data[0]?.code || '';
setCodeMap(activeKey.value, 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"
>
<CodeEditor
class="max-h-200"
:value="codeMap.get(activeKey)"
mode="application/json"
:readonly="true"
:bordered="true"
:auto-format="false"
/>
</div>
</Tabs.TabPane>
<template #rightExtra>
<Button theme="primary" ghost @click="copyCode">
<IconifyIcon icon="lucide:copy" />
复制代码
</Button>
</template>
</Tabs>
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,187 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
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: '请输入参数名称',
allowClear: true,
},
},
{
fieldName: 'key',
label: '参数键名',
component: 'Input',
componentProps: {
placeholder: '请输入参数键名',
allowClear: 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(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
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',
},
{
title: '操作',
width: 160,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,183 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraConfigApi } from '#/api/infra/config';
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message } from '#/adapter/tdesign';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteConfig,
deleteConfigList,
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 handleRefresh() {
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportConfig(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '参数配置.xls', source: data });
}
/** 创建参数 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑参数 */
function handleEdit(row: InfraConfigApi.Config) {
formModalApi.setData(row).open();
}
/** 删除参数 */
async function handleDelete(row: InfraConfigApi.Config) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
});
try {
await deleteConfig(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
/** 批量删除参数 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading({
content: $t('ui.actionMessage.deletingBatch'),
duration: 0,
});
try {
await deleteConfigList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: InfraConfigApi.Config[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getConfigPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<InfraConfigApi.Config>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<Grid table-title="参数列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['参数']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['infra:config:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['infra:config:export'],
onClick: handleExport,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'danger',
icon: ACTION_ICON.DELETE,
disabled: isEmpty(checkedIds),
auth: ['infra:config:delete'],
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
variant: 'text',
icon: ACTION_ICON.EDIT,
auth: ['infra:config:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
variant: 'text',
type: 'danger',
icon: ACTION_ICON.DELETE,
auth: ['infra:config:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,81 @@
<script lang="ts" setup>
import type { InfraConfigApi } from '#/api/infra/config';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/tdesign';
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);
// 设置到 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,92 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
/** 新增/修改的表单 */
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(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
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',
},
{
title: '操作',
width: 160,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,162 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraDataSourceConfigApi } from '#/api/infra/data-source-config';
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message } from '#/adapter/tdesign';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDataSourceConfig,
deleteDataSourceConfigList,
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 handleRefresh() {
gridApi.query();
}
/** 创建数据源 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑数据源 */
function handleEdit(row: InfraDataSourceConfigApi.DataSourceConfig) {
formModalApi.setData(row).open();
}
/** 删除数据源 */
async function handleDelete(row: InfraDataSourceConfigApi.DataSourceConfig) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
});
try {
await deleteDataSourceConfig(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
/** 批量删除数据源 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading({
content: $t('ui.actionMessage.deletingBatch'),
duration: 0,
});
try {
await deleteDataSourceConfigList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: InfraDataSourceConfigApi.DataSourceConfig[];
}) {
// 过滤掉id为 0 的主数据源
checkedIds.value = records.map((item) => item.id!).filter((id) => id !== 0);
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
rowConfig: {
keyField: 'id',
isHover: true,
},
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: getDataSourceConfigList,
},
},
} as VxeTableGridOptions<InfraDataSourceConfigApi.DataSourceConfig>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<Grid table-title="数据源列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['数据源']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['infra:data-source-config:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'danger',
icon: ACTION_ICON.DELETE,
disabled: isEmpty(checkedIds),
auth: ['infra:data-source-config:delete'],
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
variant: 'text',
icon: ACTION_ICON.EDIT,
auth: ['infra:data-source-config:update'],
disabled: row.id === 0,
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
variant: 'text',
type: 'danger',
icon: ACTION_ICON.DELETE,
auth: ['infra:data-source-config:delete'],
disabled: row.id === 0,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,88 @@
<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 { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/tdesign';
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);
// 设置到 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,152 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo01ContactApi } from '#/api/infra/demo/demo01';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
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(): VxeTableGridOptions<Demo01ContactApi.Demo01Contact>['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
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',
},
{
title: '操作',
width: 160,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,185 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo01ContactApi } from '#/api/infra/demo/demo01';
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message } from '#/adapter/tdesign';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDemo01Contact,
deleteDemo01ContactList,
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 handleRefresh() {
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportDemo01Contact(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '示例联系人.xls', source: data });
}
/** 创建示例联系人 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑示例联系人 */
function handleEdit(row: Demo01ContactApi.Demo01Contact) {
formModalApi.setData(row).open();
}
/** 删除示例联系人 */
async function handleDelete(row: Demo01ContactApi.Demo01Contact) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
});
try {
await deleteDemo01Contact(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
/** 批量删除示例联系人 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading({
content: $t('ui.actionMessage.deletingBatch'),
duration: 0,
});
try {
await deleteDemo01ContactList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: Demo01ContactApi.Demo01Contact[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
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: true,
search: true,
},
} as VxeTableGridOptions<Demo01ContactApi.Demo01Contact>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<Grid table-title="示例联系人列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['示例联系人']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['infra:demo01-contact:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['infra:demo01-contact:export'],
onClick: handleExport,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'danger',
icon: ACTION_ICON.DELETE,
disabled: isEmpty(checkedIds),
auth: ['infra:demo01-contact:delete'],
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
variant: 'text',
icon: ACTION_ICON.EDIT,
auth: ['infra:demo01-contact:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
variant: 'text',
type: 'danger',
icon: ACTION_ICON.DELETE,
auth: ['infra:demo01-contact:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,87 @@
<script lang="ts" setup>
import type { Demo01ContactApi } from '#/api/infra/demo/demo01';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/tdesign';
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($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<Demo01ContactApi.Demo01Contact>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getDemo01Contact(data.id);
// 设置到 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,120 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo02CategoryApi } from '#/api/infra/demo/demo02';
import { handleTree } from '@vben/utils';
import { getDemo02CategoryList } from '#/api/infra/demo/demo02';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'parentId',
label: '上级示例分类',
component: 'ApiTreeSelect',
componentProps: {
allowClear: true,
api: async () => {
const data = await getDemo02CategoryList({});
data.unshift({
id: 0,
name: '顶级示例分类',
});
return handleTree(data);
},
labelField: 'name',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择上级示例分类',
treeDefaultExpandAll: true,
},
rules: 'selectRequired',
},
{
fieldName: 'name',
label: '名字',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入名字',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '名字',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入名字',
},
},
{
fieldName: 'parentId',
label: '父级编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入父级编号',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<Demo02CategoryApi.Demo02Category>['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 120,
},
{
field: 'name',
title: '名字',
minWidth: 120,
treeNode: true,
},
{
field: 'parentId',
title: '父级编号',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 220,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,175 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo02CategoryApi } from '#/api/infra/demo/demo02';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from '#/adapter/tdesign';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDemo02Category,
exportDemo02Category,
getDemo02CategoryList,
} from '#/api/infra/demo/demo02';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportDemo02Category(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '示例分类.xls', source: data });
}
/** 创建示例分类 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑示例分类 */
function handleEdit(row: Demo02CategoryApi.Demo02Category) {
formModalApi.setData(row).open();
}
/** 添加下级示例分类 */
function handleAppend(row: Demo02CategoryApi.Demo02Category) {
formModalApi.setData({ parentId: row.id }).open();
}
/** 删除示例分类 */
async function handleDelete(row: Demo02CategoryApi.Demo02Category) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
});
try {
await deleteDemo02Category(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
/** 切换树形展开/收缩状态 */
const isExpanded = ref(true);
function handleExpand() {
isExpanded.value = !isExpanded.value;
gridApi.grid.setAllTreeExpand(isExpanded.value);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
treeConfig: {
parentField: 'parentId',
rowField: 'id',
transform: true,
expandAll: true,
reserve: true,
},
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_, formValues) => {
return await getDemo02CategoryList(formValues);
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<Demo02CategoryApi.Demo02Category>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<Grid table-title="示例分类列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['示例分类']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['infra:demo02-category:create'],
onClick: handleCreate,
},
{
label: isExpanded ? '收缩' : '展开',
type: 'primary',
onClick: handleExpand,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['infra:demo02-category:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '新增下级',
type: 'primary',
variant: 'text',
icon: ACTION_ICON.ADD,
auth: ['infra:demo02-category:create'],
onClick: handleAppend.bind(null, row),
},
{
label: $t('common.edit'),
type: 'primary',
variant: 'text',
icon: ACTION_ICON.EDIT,
auth: ['infra:demo02-category:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
variant: 'text',
icon: ACTION_ICON.DELETE,
auth: ['infra:demo02-category:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,90 @@
<script lang="ts" setup>
import type { Demo02CategoryApi } from '#/api/infra/demo/demo02';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/tdesign';
import {
createDemo02Category,
getDemo02Category,
updateDemo02Category,
} from '#/api/infra/demo/demo02';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<Demo02CategoryApi.Demo02Category>();
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: 100,
},
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 Demo02CategoryApi.Demo02Category;
try {
await (formData.value?.id
? updateDemo02Category(data)
: createDemo02Category(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<Demo02CategoryApi.Demo02Category>();
if (!data || !data.id) {
// 设置上级
await formApi.setValues(data);
return;
}
modalApi.lock();
try {
formData.value = await getDemo02Category(data.id);
// 设置到 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,381 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
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',
},
];
}
/** 列表的搜索表单 */
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: 'description',
label: '简介',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入简介',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<Demo03StudentApi.Demo03Student>['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
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: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 280,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
// ==================== 子表(学生课程) ====================
/** 新增/修改的表单 */
export function useDemo03CourseFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '名字',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入名字',
},
},
{
fieldName: 'score',
label: '分数',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入分数',
},
},
];
}
/** 列表的搜索表单 */
export function useDemo03CourseGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'studentId',
label: '学生编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入学生编号',
},
},
{
fieldName: 'name',
label: '名字',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入名字',
},
},
{
fieldName: 'score',
label: '分数',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入分数',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useDemo03CourseGridColumns(): VxeTableGridOptions<Demo03StudentApi.Demo03Course>['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '编号',
minWidth: 120,
},
{
field: 'studentId',
title: '学生编号',
minWidth: 120,
},
{
field: 'name',
title: '名字',
minWidth: 120,
},
{
field: 'score',
title: '分数',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 280,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
// ==================== 子表(学生班级) ====================
/** 新增/修改的表单 */
export function useDemo03GradeFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '名字',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入名字',
},
},
{
fieldName: 'teacher',
label: '班主任',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入班主任',
},
},
];
}
/** 列表的搜索表单 */
export function useDemo03GradeGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'studentId',
label: '学生编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入学生编号',
},
},
{
fieldName: 'name',
label: '名字',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入名字',
},
},
{
fieldName: 'teacher',
label: '班主任',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入班主任',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useDemo03GradeGridColumns(): VxeTableGridOptions<Demo03StudentApi.Demo03Grade>['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '编号',
minWidth: 120,
},
{
field: 'studentId',
title: '学生编号',
minWidth: 120,
},
{
field: 'name',
title: '名字',
minWidth: 120,
},
{
field: 'teacher',
title: '班主任',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 280,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,210 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { Tabs } from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDemo03Student,
deleteDemo03StudentList,
exportDemo03Student,
getDemo03StudentPage,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Demo03CourseList from './modules/demo03-course-list.vue';
import Demo03GradeList from './modules/demo03-grade-list.vue';
import Form from './modules/form.vue';
/** 子表的列表 */
const subTabsName = ref('demo03Course');
const selectDemo03Student = ref<Demo03StudentApi.Demo03Student>();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportDemo03Student(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '学生.xls', source: data });
}
/** 创建学生 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑学生 */
function handleEdit(row: Demo03StudentApi.Demo03Student) {
formModalApi.setData(row).open();
}
/** 删除学生 */
async function handleDelete(row: Demo03StudentApi.Demo03Student) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
});
try {
await deleteDemo03Student(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
/** 批量删除学生 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading({
content: $t('ui.actionMessage.deletingBatch'),
duration: 0,
});
try {
await deleteDemo03StudentList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: Demo03StudentApi.Demo03Student[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: '600px',
pagerConfig: {
enabled: true,
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDemo03StudentPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
isCurrent: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<Demo03StudentApi.Demo03Student>,
gridEvents: {
cellClick: ({ row }: { row: Demo03StudentApi.Demo03Student }) => {
selectDemo03Student.value = row;
},
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<div>
<Grid table-title="学生列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['学生']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['infra:demo03-student:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['infra:demo03-student:export'],
onClick: handleExport,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'danger',
icon: ACTION_ICON.DELETE,
disabled: isEmpty(checkedIds),
auth: ['infra:demo03-student:delete'],
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
variant: 'text',
icon: ACTION_ICON.EDIT,
auth: ['infra:demo03-student:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
variant: 'text',
icon: ACTION_ICON.DELETE,
auth: ['infra:demo03-student:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.id]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
<!-- 子表的表单 -->
<Tabs v-model:active-key="subTabsName" class="mt-2">
<Tabs.TabPane key="demo03Course" tab="学生课程" force-render>
<Demo03CourseList :student-id="selectDemo03Student?.id" />
</Tabs.TabPane>
<Tabs.TabPane key="demo03Grade" tab="学生班级" force-render>
<Demo03GradeList :student-id="selectDemo03Student?.id" />
</Tabs.TabPane>
</Tabs>
</div>
</Page>
</template>

View File

@@ -0,0 +1,91 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/tdesign';
import {
createDemo03Course,
getDemo03Course,
updateDemo03Course,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
import { useDemo03CourseFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<Demo03StudentApi.Demo03Course>();
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: useDemo03CourseFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as Demo03StudentApi.Demo03Course;
data.studentId = formData.value?.studentId;
try {
await (formData.value?.id
? updateDemo03Course(data)
: createDemo03Course(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;
}
// 加载数据
let data = modalApi.getData<Demo03StudentApi.Demo03Course>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Course(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>

View File

@@ -0,0 +1,197 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { nextTick, ref, watch } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message } from '#/adapter/tdesign';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDemo03Course,
deleteDemo03CourseList,
getDemo03CoursePage,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
import {
useDemo03CourseGridColumns,
useDemo03CourseGridFormSchema,
} from '../data';
import Demo03CourseForm from './demo03-course-form.vue';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo03CourseForm,
destroyOnClose: true,
});
/** 创建学生课程 */
function handleCreate() {
if (!props.studentId) {
message.warning('请先选择一个学生!');
return;
}
formModalApi.setData({ studentId: props.studentId }).open();
}
/** 编辑学生课程 */
function handleEdit(row: Demo03StudentApi.Demo03Course) {
formModalApi.setData(row).open();
}
/** 删除学生课程 */
async function handleDelete(row: Demo03StudentApi.Demo03Course) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
});
try {
await deleteDemo03Course(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
await handleRefresh();
} finally {
message.close(hideLoading);
}
}
/** 批量删除学生课程 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading({
content: $t('ui.actionMessage.deletingBatch'),
duration: 0,
});
try {
await deleteDemo03CourseList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
await handleRefresh();
} finally {
message.close(hideLoading);
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: Demo03StudentApi.Demo03Course[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useDemo03CourseGridFormSchema(),
},
gridOptions: {
columns: useDemo03CourseGridColumns(),
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
if (!props.studentId) {
return [];
}
return await getDemo03CoursePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
studentId: props.studentId,
...formValues,
});
},
},
},
pagerConfig: {
enabled: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
height: '600px',
rowConfig: {
keyField: 'id',
isHover: true,
},
} as VxeTableGridOptions<Demo03StudentApi.Demo03Course>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
/** 刷新表格 */
async function handleRefresh() {
await gridApi.query();
}
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await handleRefresh();
},
{ immediate: true },
);
</script>
<template>
<FormModal @success="handleRefresh" />
<Grid table-title="学生课程列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['学生课程']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['infra:demo03-student:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'danger',
icon: ACTION_ICON.DELETE,
auth: ['infra:demo03-student:delete'],
disabled: isEmpty(checkedIds),
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
variant: 'text',
icon: ACTION_ICON.EDIT,
auth: ['infra:demo03-student:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
variant: 'text',
type: 'danger',
icon: ACTION_ICON.DELETE,
auth: ['infra:demo03-student:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.id]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,91 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/tdesign';
import {
createDemo03Grade,
getDemo03Grade,
updateDemo03Grade,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
import { useDemo03GradeFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<Demo03StudentApi.Demo03Grade>();
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: useDemo03GradeFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as Demo03StudentApi.Demo03Grade;
data.studentId = formData.value?.studentId;
try {
await (formData.value?.id
? updateDemo03Grade(data)
: createDemo03Grade(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;
}
// 加载数据
let data = modalApi.getData<Demo03StudentApi.Demo03Grade>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Grade(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>

View File

@@ -0,0 +1,197 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { nextTick, ref, watch } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message } from '#/adapter/tdesign';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDemo03Grade,
deleteDemo03GradeList,
getDemo03GradePage,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
import {
useDemo03GradeGridColumns,
useDemo03GradeGridFormSchema,
} from '../data';
import Demo03GradeForm from './demo03-grade-form.vue';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo03GradeForm,
destroyOnClose: true,
});
/** 创建学生班级 */
function handleCreate() {
if (!props.studentId) {
message.warning('请先选择一个学生!');
return;
}
formModalApi.setData({ studentId: props.studentId }).open();
}
/** 编辑学生班级 */
function handleEdit(row: Demo03StudentApi.Demo03Grade) {
formModalApi.setData(row).open();
}
/** 删除学生班级 */
async function handleDelete(row: Demo03StudentApi.Demo03Grade) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
});
try {
await deleteDemo03Grade(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
await handleRefresh();
} finally {
message.close(hideLoading);
}
}
/** 批量删除学生班级 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading({
content: $t('ui.actionMessage.deletingBatch'),
duration: 0,
});
try {
await deleteDemo03GradeList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
await handleRefresh();
} finally {
message.close(hideLoading);
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: Demo03StudentApi.Demo03Grade[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useDemo03GradeGridFormSchema(),
},
gridOptions: {
columns: useDemo03GradeGridColumns(),
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
if (!props.studentId) {
return [];
}
return await getDemo03GradePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
studentId: props.studentId,
...formValues,
});
},
},
},
pagerConfig: {
enabled: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
height: '600px',
rowConfig: {
keyField: 'id',
isHover: true,
},
} as VxeTableGridOptions<Demo03StudentApi.Demo03Grade>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
/** 刷新表格 */
async function handleRefresh() {
await gridApi.query();
}
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await handleRefresh();
},
{ immediate: true },
);
</script>
<template>
<FormModal @success="handleRefresh" />
<Grid table-title="学生班级列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['学生班级']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['infra:demo03-student:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'danger',
icon: ACTION_ICON.DELETE,
auth: ['infra:demo03-student:delete'],
disabled: isEmpty(checkedIds),
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
variant: 'text',
icon: ACTION_ICON.EDIT,
auth: ['infra:demo03-student:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
variant: 'text',
icon: ACTION_ICON.DELETE,
auth: ['infra:demo03-student:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.id]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,87 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/tdesign';
import {
createDemo03Student,
getDemo03Student,
updateDemo03Student,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<Demo03StudentApi.Demo03Student>();
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 Demo03StudentApi.Demo03Student;
try {
await (formData.value?.id
? updateDemo03Student(data)
: createDemo03Student(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<Demo03StudentApi.Demo03Student>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getDemo03Student(data.id);
// 设置到 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,276 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/inner';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
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',
},
];
}
/** 列表的搜索表单 */
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: 'description',
label: '简介',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入简介',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<Demo03StudentApi.Demo03Student>['columns'] {
return [
{ type: 'checkbox', width: 40 },
{ type: 'expand', width: 80, slots: { content: 'expand_content' } },
{
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: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 280,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
// ==================== 子表(学生课程) ====================
/** 新增/修改列表的字段 */
export function useDemo03CourseGridEditColumns(): VxeTableGridOptions<Demo03StudentApi.Demo03Course>['columns'] {
return [
{
field: 'name',
title: '名字',
minWidth: 120,
slots: { default: 'name' },
},
{
field: 'score',
title: '分数',
minWidth: 120,
slots: { default: 'score' },
},
{
title: '操作',
width: 280,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 列表的字段 */
export function useDemo03CourseGridColumns(): VxeTableGridOptions<Demo03StudentApi.Demo03Course>['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 120,
},
{
field: 'studentId',
title: '学生编号',
minWidth: 120,
},
{
field: 'name',
title: '名字',
minWidth: 120,
},
{
field: 'score',
title: '分数',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
];
}
// ==================== 子表(学生班级) ====================
/** 新增/修改的表单 */
export function useDemo03GradeFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '名字',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入名字',
},
},
{
fieldName: 'teacher',
label: '班主任',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入班主任',
},
},
];
}
/** 列表的字段 */
export function useDemo03GradeGridColumns(): VxeTableGridOptions<Demo03StudentApi.Demo03Grade>['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 120,
},
{
field: 'studentId',
title: '学生编号',
minWidth: 120,
},
{
field: 'name',
title: '名字',
minWidth: 120,
},
{
field: 'teacher',
title: '班主任',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
];
}

View File

@@ -0,0 +1,204 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/inner';
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { Tabs } from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDemo03Student,
deleteDemo03StudentList,
exportDemo03Student,
getDemo03StudentPage,
} from '#/api/infra/demo/demo03/inner';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Demo03CourseList from './modules/demo03-course-list.vue';
import Demo03GradeList from './modules/demo03-grade-list.vue';
import Form from './modules/form.vue';
/** 子表的列表 */
const subTabsName = ref('demo03Course');
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.reload();
}
/** 导出表格 */
async function handleExport() {
const data = await exportDemo03Student(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '学生.xls', source: data });
}
/** 创建学生 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑学生 */
function handleEdit(row: Demo03StudentApi.Demo03Student) {
formModalApi.setData(row).open();
}
/** 删除学生 */
async function handleDelete(row: Demo03StudentApi.Demo03Student) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
});
try {
await deleteDemo03Student(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
/** 批量删除学生 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading({
content: $t('ui.actionMessage.deletingBatch'),
duration: 0,
});
try {
await deleteDemo03StudentList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: Demo03StudentApi.Demo03Student[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
pagerConfig: {
enabled: true,
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDemo03StudentPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<Demo03StudentApi.Demo03Student>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<Grid table-title="学生列表">
<template #expand_content="{ row }">
<!-- 子表的表单 -->
<Tabs v-model:active-key="subTabsName" class="mx-8">
<Tabs.TabPane key="demo03Course" tab="学生课程" force-render>
<Demo03CourseList :student-id="row?.id" />
</Tabs.TabPane>
<Tabs.TabPane key="demo03Grade" tab="学生班级" force-render>
<Demo03GradeList :student-id="row?.id" />
</Tabs.TabPane>
</Tabs>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['学生']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['infra:demo03-student:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['infra:demo03-student:export'],
onClick: handleExport,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'danger',
icon: ACTION_ICON.DELETE,
auth: ['infra:demo03-student:delete'],
disabled: isEmpty(checkedIds),
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
variant: 'text',
icon: ACTION_ICON.EDIT,
auth: ['infra:demo03-student:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
variant: 'text',
icon: ACTION_ICON.DELETE,
auth: ['infra:demo03-student:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.id]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,120 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/inner';
import { nextTick, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Button, Input } from 'tdesign-vue-next';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDemo03CourseListByStudentId } from '#/api/infra/demo/demo03/inner';
import { $t } from '#/locales';
import { useDemo03CourseGridEditColumns } from '../data';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useDemo03CourseGridEditColumns(),
border: true,
showOverflow: true,
autoResize: true,
keepSource: true,
rowConfig: {
keyField: 'id',
isHover: true,
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
},
});
/** 添加学生课程 */
async function handleAdd() {
await gridApi.grid.insertAt({} as Demo03StudentApi.Demo03Course, -1);
}
/** 删除学生课程 */
async function handleDelete(row: Demo03StudentApi.Demo03Course) {
await gridApi.grid.remove(row);
}
/** 提供获取表格数据的方法供父组件调用 */
defineExpose({
getData: (): Demo03StudentApi.Demo03Course[] => {
const data = gridApi.grid.getData() as Demo03StudentApi.Demo03Course[];
const removeRecords =
gridApi.grid.getRemoveRecords() as Demo03StudentApi.Demo03Course[];
const insertRecords =
gridApi.grid.getInsertRecords() as Demo03StudentApi.Demo03Course[];
return [
...data.filter(
(row) => !removeRecords.some((removed) => removed.id === row.id),
),
...insertRecords.map((row: any) => ({ ...row, id: undefined })),
];
},
});
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await gridApi.grid.loadData(
await getDemo03CourseListByStudentId(props.studentId!),
);
},
{ immediate: true },
);
</script>
<template>
<Grid class="mx-4">
<template #name="{ row }">
<Input v-model="row.name" />
</template>
<template #score="{ row }">
<Input v-model="row.score" />
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.delete'),
type: 'danger',
variant: 'text',
icon: ACTION_ICON.DELETE,
auth: ['infra:demo03-student:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.id]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
<div class="-mt-4 flex justify-center">
<Button
theme="primary"
ghost
@click="handleAdd"
v-access:code="['infra:demo03-student:create']"
>
<IconifyIcon icon="lucide:plus" />
{{ $t('ui.actionTitle.create', ['学生课程']) }}
</Button>
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/inner';
import { nextTick, watch } from 'vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDemo03CourseListByStudentId } from '#/api/infra/demo/demo03/inner';
import { useDemo03CourseGridColumns } from '../data';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useDemo03CourseGridColumns(),
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
height: '600px',
rowConfig: {
keyField: 'id',
isHover: true,
},
} as VxeTableGridOptions<Demo03StudentApi.Demo03Course>,
});
/** 刷新表格 */
async function handleRefresh() {
await gridApi.grid.loadData(
await getDemo03CourseListByStudentId(props.studentId!),
);
}
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await handleRefresh();
},
{ immediate: true },
);
</script>
<template>
<Grid table-title="学生课程列表" />
</template>

View File

@@ -0,0 +1,51 @@
<script lang="ts" setup>
import { nextTick, watch } from 'vue';
import { useVbenForm } from '#/adapter/form';
import { getDemo03GradeByStudentId } from '#/api/infra/demo/demo03/inner';
import { useDemo03GradeFormSchema } from '../data';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useDemo03GradeFormSchema(),
showDefaultActions: false,
});
/** 暴露出表单校验方法和表单值获取方法 */
defineExpose({
validate: async () => {
const { valid } = await formApi.validate();
return valid;
},
getValues: formApi.getValues,
});
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await formApi.setValues(await getDemo03GradeByStudentId(props.studentId!));
},
{ immediate: true },
);
</script>
<template>
<Form class="mx-4" />
</template>

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/inner';
import { nextTick, watch } from 'vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDemo03GradeByStudentId } from '#/api/infra/demo/demo03/inner';
import { useDemo03GradeGridColumns } from '../data';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useDemo03GradeGridColumns(),
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
height: '600px',
rowConfig: {
keyField: 'id',
isHover: true,
},
} as VxeTableGridOptions<Demo03StudentApi.Demo03Grade>,
});
/** 刷新表格 */
async function handleRefresh() {
await gridApi.grid.loadData([
await getDemo03GradeByStudentId(props.studentId!),
]);
}
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await handleRefresh();
},
{ immediate: true },
);
</script>
<template>
<Grid table-title="学生班级列表" />
</template>

View File

@@ -0,0 +1,120 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/inner';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Tabs } from 'tdesign-vue-next';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/tdesign';
import {
createDemo03Student,
getDemo03Student,
updateDemo03Student,
} from '#/api/infra/demo/demo03/inner';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
import Demo03CourseForm from './demo03-course-form.vue';
import Demo03GradeForm from './demo03-grade-form.vue';
const emit = defineEmits(['success']);
const formData = ref<Demo03StudentApi.Demo03Student>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['学生'])
: $t('ui.actionTitle.create', ['学生']);
});
/** 子表的表单 */
const subTabsName = ref('demo03Course');
const demo03CourseFormRef = ref<InstanceType<typeof Demo03CourseForm>>();
const demo03GradeFormRef = ref<InstanceType<typeof Demo03GradeForm>>();
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;
}
// 校验子表单
const demo03GradeValid = await demo03GradeFormRef.value?.validate();
if (!demo03GradeValid) {
subTabsName.value = 'demo03Grade';
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as Demo03StudentApi.Demo03Student;
// 拼接子表的数据
data.demo03courses = demo03CourseFormRef.value?.getData();
data.demo03grade = await demo03GradeFormRef.value?.getValues();
try {
await (formData.value?.id
? updateDemo03Student(data)
: createDemo03Student(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;
}
// 加载数据
let data = modalApi.getData<Demo03StudentApi.Demo03Student>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Student(data.id);
} finally {
modalApi.unlock();
}
}
// 设置到 values
formData.value = data;
await formApi.setValues(formData.value);
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
<!-- 子表的表单 -->
<Tabs v-model:active-key="subTabsName">
<Tabs.TabPane key="demo03Course" tab="学生课程" force-render>
<Demo03CourseForm
ref="demo03CourseFormRef"
:student-id="formData?.id"
/>
</Tabs.TabPane>
<Tabs.TabPane key="demo03Grade" tab="学生班级" force-render>
<Demo03GradeForm ref="demo03GradeFormRef" :student-id="formData?.id" />
</Tabs.TabPane>
</Tabs>
</Modal>
</template>

View File

@@ -0,0 +1,211 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
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',
},
];
}
/** 列表的搜索表单 */
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: 'description',
label: '简介',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入简介',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<Demo03StudentApi.Demo03Student>['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
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: 'createTime',
title: '创建时间',
minWidth: 120,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 200,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
// ==================== 子表(学生课程) ====================
/** 新增/修改列表的字段 */
export function useDemo03CourseGridEditColumns(): VxeTableGridOptions<Demo03StudentApi.Demo03Course>['columns'] {
return [
{
field: 'name',
title: '名字',
minWidth: 120,
slots: { default: 'name' },
},
{
field: 'score',
title: '分数',
minWidth: 120,
slots: { default: 'score' },
},
{
title: '操作',
width: 200,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
// ==================== 子表(学生班级) ====================
/** 新增/修改的表单 */
export function useDemo03GradeFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '名字',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入名字',
},
},
{
fieldName: 'teacher',
label: '班主任',
rules: 'required',
component: 'Input',
componentProps: {
placeholder: '请输入班主任',
},
},
];
}

View File

@@ -0,0 +1,185 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message } from '#/adapter/tdesign';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDemo03Student,
deleteDemo03StudentList,
exportDemo03Student,
getDemo03StudentPage,
} from '#/api/infra/demo/demo03/normal';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportDemo03Student(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '学生.xls', source: data });
}
/** 创建学生 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑学生 */
function handleEdit(row: Demo03StudentApi.Demo03Student) {
formModalApi.setData(row).open();
}
/** 删除学生 */
async function handleDelete(row: Demo03StudentApi.Demo03Student) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
});
try {
await deleteDemo03Student(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
/** 批量删除学生 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading({
content: $t('ui.actionMessage.deletingBatch'),
duration: 0,
});
try {
await deleteDemo03StudentList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: Demo03StudentApi.Demo03Student[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
pagerConfig: {
enabled: true,
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDemo03StudentPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<Demo03StudentApi.Demo03Student>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<Grid table-title="学生列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['学生']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['infra:demo03-student:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['infra:demo03-student:export'],
onClick: handleExport,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'danger',
icon: ACTION_ICON.DELETE,
disabled: isEmpty(checkedIds),
auth: ['infra:demo03-student:delete'],
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
variant: 'text',
icon: ACTION_ICON.EDIT,
auth: ['infra:demo03-student:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
variant: 'text',
type: 'danger',
icon: ACTION_ICON.DELETE,
auth: ['infra:demo03-student:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.id]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,119 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { nextTick, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Button, Input } from 'tdesign-vue-next';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDemo03CourseListByStudentId } from '#/api/infra/demo/demo03/normal';
import { $t } from '#/locales';
import { useDemo03CourseGridEditColumns } from '../data';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useDemo03CourseGridEditColumns(),
border: true,
showOverflow: true,
autoResize: true,
keepSource: true,
rowConfig: {
keyField: 'id',
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
},
});
/** 添加学生课程 */
async function handleAdd() {
await gridApi.grid.insertAt({} as Demo03StudentApi.Demo03Course, -1);
}
/** 删除学生课程 */
async function handleDelete(row: Demo03StudentApi.Demo03Course) {
await gridApi.grid.remove(row);
}
/** 提供获取表格数据的方法供父组件调用 */
defineExpose({
getData: (): Demo03StudentApi.Demo03Course[] => {
const data = gridApi.grid.getData() as Demo03StudentApi.Demo03Course[];
const removeRecords =
gridApi.grid.getRemoveRecords() as Demo03StudentApi.Demo03Course[];
const insertRecords =
gridApi.grid.getInsertRecords() as Demo03StudentApi.Demo03Course[];
return [
...data.filter(
(row) => !removeRecords.some((removed) => removed.id === row.id),
),
...insertRecords.map((row: any) => ({ ...row, id: undefined })),
];
},
});
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await gridApi.grid.loadData(
await getDemo03CourseListByStudentId(props.studentId!),
);
},
{ immediate: true },
);
</script>
<template>
<Grid class="mx-4">
<template #name="{ row }">
<Input v-model="row.name" />
</template>
<template #score="{ row }">
<Input v-model="row.score" />
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.delete'),
type: 'danger',
variant: 'text',
icon: ACTION_ICON.DELETE,
auth: ['infra:demo03-student:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.id]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
<div class="-mt-4 flex justify-center">
<Button
theme="primary"
ghost
@click="handleAdd"
v-access:code="['infra:demo03-student:create']"
>
<IconifyIcon icon="lucide:plus" />
{{ $t('ui.actionTitle.create', ['学生课程']) }}
</Button>
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script lang="ts" setup>
import { nextTick, watch } from 'vue';
import { useVbenForm } from '#/adapter/form';
import { getDemo03GradeByStudentId } from '#/api/infra/demo/demo03/normal';
import { useDemo03GradeFormSchema } from '../data';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useDemo03GradeFormSchema(),
showDefaultActions: false,
});
/** 暴露出表单校验方法和表单值获取方法 */
defineExpose({
validate: async () => {
const { valid } = await formApi.validate();
return valid;
},
getValues: formApi.getValues,
});
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await formApi.setValues(await getDemo03GradeByStudentId(props.studentId!));
},
{ immediate: true },
);
</script>
<template>
<Form class="mx-4" />
</template>

View File

@@ -0,0 +1,120 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Tabs } from 'tdesign-vue-next';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/tdesign';
import {
createDemo03Student,
getDemo03Student,
updateDemo03Student,
} from '#/api/infra/demo/demo03/normal';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
import Demo03CourseForm from './demo03-course-form.vue';
import Demo03GradeForm from './demo03-grade-form.vue';
const emit = defineEmits(['success']);
const formData = ref<Demo03StudentApi.Demo03Student>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['学生'])
: $t('ui.actionTitle.create', ['学生']);
});
/** 子表的表单 */
const subTabsName = ref('demo03Course');
const demo03CourseFormRef = ref<InstanceType<typeof Demo03CourseForm>>();
const demo03GradeFormRef = ref<InstanceType<typeof Demo03GradeForm>>();
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;
}
// 校验子表单
const demo03GradeValid = await demo03GradeFormRef.value?.validate();
if (!demo03GradeValid) {
subTabsName.value = 'demo03Grade';
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as Demo03StudentApi.Demo03Student;
// 拼接子表的数据
data.demo03courses = demo03CourseFormRef.value?.getData();
data.demo03grade = await demo03GradeFormRef.value?.getValues();
try {
await (formData.value?.id
? updateDemo03Student(data)
: createDemo03Student(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;
}
// 加载数据
let data = modalApi.getData<Demo03StudentApi.Demo03Student>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Student(data.id);
} finally {
modalApi.unlock();
}
}
// 设置到 values
formData.value = data;
await formApi.setValues(formData.value);
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
<!-- 子表的表单 -->
<Tabs v-model:active-key="subTabsName">
<Tabs.TabPane key="demo03Course" tab="学生课程" force-render>
<Demo03CourseForm
ref="demo03CourseFormRef"
:student-id="formData?.id"
/>
</Tabs.TabPane>
<Tabs.TabPane key="demo03Grade" tab="学生班级" force-render>
<Demo03GradeForm ref="demo03GradeFormRef" :student-id="formData?.id" />
</Tabs.TabPane>
</Tabs>
</Modal>
</template>

View File

@@ -0,0 +1,311 @@
<script lang="ts" setup>
import type { Demo01ContactApi } from '#/api/infra/demo/demo01';
import { onMounted, reactive, ref } from 'vue';
import { ContentWrap, Page, useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { useTableToolbar, VbenVxeTableToolbar } from '@vben/plugins/vxe-table';
import {
cloneDeep,
downloadFileFromBlobPart,
formatDateTime,
isEmpty,
} from '@vben/utils';
import {
Button,
DateRangePicker,
Form,
Input,
Pagination,
Select,
} from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
deleteDemo01Contact,
deleteDemo01ContactList,
exportDemo01Contact,
getDemo01ContactPage,
} from '#/api/infra/demo/demo01';
import { DictTag } from '#/components/dict-tag';
import { $t } from '#/locales';
import { getRangePickerDefaultProps } from '#/utils';
import Demo01ContactForm from './modules/form.vue';
const loading = ref(true); // 列表的加载中
const list = ref<Demo01ContactApi.Demo01Contact[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
sex: undefined,
createTime: undefined,
});
const queryFormRef = ref(); // 搜索的表单
const exportLoading = ref(false); // 导出的加载中
/** 查询列表 */
async function getList() {
loading.value = true;
try {
const params = cloneDeep(queryParams) as any;
if (params.createTime && Array.isArray(params.createTime)) {
params.createTime = (params.createTime as string[]).join(',');
}
const data = await getDemo01ContactPage(params);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.pageNo = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
queryFormRef.value.resetFields();
handleQuery();
}
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo01ContactForm,
destroyOnClose: true,
});
/** 创建示例联系人 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑示例联系人 */
function handleEdit(row: Demo01ContactApi.Demo01Contact) {
formModalApi.setData(row).open();
}
/** 删除示例联系人 */
async function handleDelete(row: Demo01ContactApi.Demo01Contact) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
});
try {
await deleteDemo01Contact(row.id!);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.id]),
});
await getList();
} finally {
message.close(hideLoading);
}
}
/** 批量删除示例联系人 */
async function handleDeleteBatch() {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting'),
duration: 0,
});
try {
await deleteDemo01ContactList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
await getList();
} finally {
message.close(hideLoading);
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: Demo01ContactApi.Demo01Contact[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
/** 导出表格 */
async function handleExport() {
try {
exportLoading.value = true;
const data = await exportDemo01Contact(queryParams);
downloadFileFromBlobPart({ fileName: '示例联系人.xls', source: data });
} finally {
exportLoading.value = false;
}
}
/** 初始化 */
const { hiddenSearchBar, tableToolbarRef, tableRef } = useTableToolbar();
onMounted(() => {
getList();
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="getList" />
<ContentWrap v-if="!hiddenSearchBar">
<!-- 搜索工作栏 -->
<Form :model="queryParams" ref="queryFormRef" layout="inline">
<Form.Item label="名字" name="name">
<Input
v-model="queryParams.name"
placeholder="请输入名字"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="性别" name="sex">
<Select
v-model="queryParams.sex"
placeholder="请选择性别"
allow-clear
class="w-full"
>
<Select.Option
v-for="dict in getDictOptions(
DICT_TYPE.SYSTEM_USER_SEX,
'number',
)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="创建时间" name="createTime">
<DateRangePicker
v-model="queryParams.createTime"
v-bind="getRangePickerDefaultProps()"
class="w-full"
/>
</Form.Item>
<Form.Item>
<Button class="ml-2" @click="resetQuery"> 重置 </Button>
<Button class="ml-2" @click="handleQuery" theme="primary">
搜索
</Button>
</Form.Item>
</Form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap title="示例联系人">
<template #extra>
<VbenVxeTableToolbar
ref="tableToolbarRef"
v-model:hidden-search="hiddenSearchBar"
>
<Button
class="ml-2"
theme="primary"
@click="handleCreate"
v-access:code="['infra:demo01-contact:create']"
>
<IconifyIcon icon="lucide:plus" />
{{ $t('ui.actionTitle.create', ['示例联系人']) }}
</Button>
<Button
theme="primary"
class="ml-2"
:loading="exportLoading"
@click="handleExport"
v-access:code="['infra:demo01-contact:export']"
>
<IconifyIcon icon="lucide:download" />
{{ $t('ui.actionTitle.export') }}
</Button>
<Button
theme="primary"
danger
class="ml-2"
:disabled="isEmpty(checkedIds)"
@click="handleDeleteBatch"
v-access:code="['infra:demo01-contact:delete']"
>
<IconifyIcon icon="lucide:trash-2" />
批量删除
</Button>
</VbenVxeTableToolbar>
</template>
<VxeTable
ref="tableRef"
:data="list"
show-overflow
:loading="loading"
@checkbox-all="handleRowCheckboxChange"
@checkbox-change="handleRowCheckboxChange"
>
<VxeColumn type="checkbox" width="40" />
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="name" title="名字" align="center" />
<VxeColumn field="sex" title="性别" align="center">
<template #default="{ row }">
<DictTag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="row.sex" />
</template>
</VxeColumn>
<VxeColumn field="birthday" title="出生年" align="center">
<template #default="{ row }">
{{ formatDateTime(row.birthday) }}
</template>
</VxeColumn>
<VxeColumn field="description" title="简介" align="center" />
<VxeColumn field="avatar" title="头像" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
variant="text"
@click="handleEdit(row)"
v-access:code="['infra:demo01-contact:update']"
>
{{ $t('ui.actionTitle.edit') }}
</Button>
<Button
size="small"
variant="text"
theme="danger"
class="ml-2"
@click="handleDelete(row)"
v-access:code="['infra:demo01-contact:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
<!-- 分页 -->
<div class="mt-2 flex justify-end">
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
show-size-changer
@change="getList"
/>
</div>
</ContentWrap>
</Page>
</template>

View File

@@ -0,0 +1,139 @@
<script lang="ts" setup>
import type { DateValue } from 'tdesign-vue-next';
import type { Demo01ContactApi } from '#/api/infra/demo/demo01';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { DatePicker, Form, Input, Radio, RadioGroup } from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import {
createDemo01Contact,
getDemo01Contact,
updateDemo01Contact,
} from '#/api/infra/demo/demo01';
import { Tinymce as RichTextarea } from '#/components/tinymce';
import { ImageUpload } from '#/components/upload';
import { $t } from '#/locales';
const emit = defineEmits(['success']);
const formRef = ref();
const formData = ref<Partial<Demo01ContactApi.Demo01Contact>>({
id: undefined,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
avatar: undefined,
});
const rules: Record<string, any[]> = {
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
sex: [{ required: true, message: '性别不能为空', trigger: 'blur' }],
birthday: [{ required: true, message: '出生年不能为空', trigger: 'blur' }],
description: [{ required: true, message: '简介不能为空', trigger: 'blur' }],
};
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['示例联系人'])
: $t('ui.actionTitle.create', ['示例联系人']);
});
/** 重置表单 */
function resetForm() {
formData.value = {
id: undefined,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
avatar: undefined,
};
formRef.value?.resetFields();
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
await formRef.value?.validate();
modalApi.lock();
// 提交表单
const data = formData.value as Demo01ContactApi.Demo01Contact;
try {
await (formData.value?.id
? updateDemo01Contact(data)
: createDemo01Contact(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetForm();
return;
}
// 加载数据
let data = modalApi.getData<Demo01ContactApi.Demo01Contact>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo01Contact(data.id);
} finally {
modalApi.unlock();
}
}
formData.value = data;
},
});
</script>
<template>
<Modal :title="getTitle">
<Form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="名字" name="name">
<Input v-model="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="性别" name="sex">
<RadioGroup v-model="formData.sex">
<Radio
v-for="dict in getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number')"
:key="dict.value.toString()"
:value="dict.value"
>
{{ dict.label }}
</Radio>
</RadioGroup>
</Form.Item>
<Form.Item label="出生年" name="birthday">
<DatePicker
v-model="formData.birthday as DateValue"
value-format="x"
placeholder="选择出生年"
/>
</Form.Item>
<Form.Item label="简介" name="description">
<RichTextarea v-model="formData.description" height="500px" />
</Form.Item>
<Form.Item label="头像" name="avatar">
<ImageUpload v-model="formData.avatar" />
</Form.Item>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,255 @@
<script lang="ts" setup>
import type { Demo02CategoryApi } from '#/api/infra/demo/demo02';
import { onMounted, reactive, ref } from 'vue';
import { ContentWrap, Page, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { useTableToolbar, VbenVxeTableToolbar } from '@vben/plugins/vxe-table';
import {
cloneDeep,
downloadFileFromBlobPart,
formatDateTime,
isEmpty,
} from '@vben/utils';
import { Button, DateRangePicker, Form, Input } from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
deleteDemo02Category,
exportDemo02Category,
getDemo02CategoryList,
} from '#/api/infra/demo/demo02';
import { $t } from '#/locales';
import { getRangePickerDefaultProps } from '#/utils';
import Demo02CategoryForm from './modules/form.vue';
const loading = ref(true); // 列表的加载中
const list = ref<any[]>([]); // 树列表的数据
const queryParams = reactive({
name: undefined,
parentId: undefined,
createTime: undefined,
});
const queryFormRef = ref(); // 搜索的表单
const exportLoading = ref(false); // 导出的加载中
/** 查询列表 */
async function getList() {
loading.value = true;
try {
const params = cloneDeep(queryParams) as any;
if (params.createTime && Array.isArray(params.createTime)) {
params.createTime = (params.createTime as string[]).join(',');
}
list.value = await getDemo02CategoryList(params);
} finally {
loading.value = false;
}
}
/** 搜索按钮操作 */
function handleQuery() {
getList();
}
/** 重置按钮操作 */
function resetQuery() {
queryFormRef.value.resetFields();
handleQuery();
}
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo02CategoryForm,
destroyOnClose: true,
});
/** 创建示例分类 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑示例分类 */
function handleEdit(row: Demo02CategoryApi.Demo02Category) {
formModalApi.setData(row).open();
}
/** 新增下级示例分类 */
function handleAppend(row: Demo02CategoryApi.Demo02Category) {
formModalApi.setData({ parentId: row.id }).open();
}
/** 删除示例分类 */
async function handleDelete(row: Demo02CategoryApi.Demo02Category) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
});
try {
await deleteDemo02Category(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.id]));
await getList();
} finally {
message.close(hideLoading);
}
}
/** 导出表格 */
async function handleExport() {
try {
exportLoading.value = true;
const data = await exportDemo02Category(queryParams);
downloadFileFromBlobPart({ fileName: '示例分类.xls', source: data });
} finally {
exportLoading.value = false;
}
}
/** 切换树形展开/收缩状态 */
const isExpanded = ref(true);
function toggleExpand() {
isExpanded.value = !isExpanded.value;
tableRef.value?.setAllTreeExpand(isExpanded.value);
}
/** 初始化 */
const { hiddenSearchBar, tableToolbarRef, tableRef } = useTableToolbar();
onMounted(() => {
getList();
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="getList" />
<ContentWrap v-if="!hiddenSearchBar">
<!-- 搜索工作栏 -->
<Form :model="queryParams" ref="queryFormRef" layout="inline">
<Form.Item label="名字" name="name">
<Input
v-model="queryParams.name"
placeholder="请输入名字"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="父级编号" name="parentId">
<Input
v-model="queryParams.parentId"
placeholder="请输入父级编号"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="创建时间" name="createTime">
<DateRangePicker
v-model="queryParams.createTime"
v-bind="getRangePickerDefaultProps()"
class="w-full"
/>
</Form.Item>
<Form.Item>
<Button class="ml-2" @click="resetQuery"> 重置 </Button>
<Button class="ml-2" @click="handleQuery" theme="primary">
搜索
</Button>
</Form.Item>
</Form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap title="示例分类">
<template #extra>
<VbenVxeTableToolbar
ref="tableToolbarRef"
v-model:hidden-search="hiddenSearchBar"
>
<Button @click="toggleExpand" class="mr-2">
{{ isExpanded ? '收缩' : '展开' }}
</Button>
<Button
class="ml-2"
theme="primary"
@click="handleCreate"
v-access:code="['infra:demo02-category:create']"
>
<IconifyIcon icon="lucide:plus" />
{{ $t('ui.actionTitle.create', ['示例分类']) }}
</Button>
<Button
theme="primary"
class="ml-2"
:loading="exportLoading"
@click="handleExport"
v-access:code="['infra:demo02-category:export']"
>
<IconifyIcon icon="lucide:download" />
{{ $t('ui.actionTitle.export') }}
</Button>
</VbenVxeTableToolbar>
</template>
<VxeTable
ref="tableRef"
:data="list"
:tree-config="{
parentField: 'parentId',
rowField: 'id',
transform: true,
expandAll: true,
reserve: true,
}"
show-overflow
:loading="loading"
>
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="name" title="名字" align="center" tree-node />
<VxeColumn field="parentId" title="父级编号" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
theme="primary"
variant="text"
@click="handleAppend(row)"
v-access:code="['infra:demo02-category:create']"
>
新增下级
</Button>
<Button
size="small"
theme="primary"
variant="text"
@click="handleEdit(row)"
v-access:code="['infra:demo02-category:update']"
>
{{ $t('ui.actionTitle.edit') }}
</Button>
<Button
size="small"
variant="text"
theme="danger"
class="ml-2"
:disabled="!isEmpty(row.children)"
@click="handleDelete(row)"
v-access:code="['infra:demo02-category:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
</ContentWrap>
</Page>
</template>

View File

@@ -0,0 +1,135 @@
<script lang="ts" setup>
import type { FormRule } from 'tdesign-vue-next';
import type { Demo02CategoryApi } from '#/api/infra/demo/demo02';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import { Form, Input, TreeSelect } from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import {
createDemo02Category,
getDemo02Category,
getDemo02CategoryList,
updateDemo02Category,
} from '#/api/infra/demo/demo02';
import { $t } from '#/locales';
type Rule = FormRule;
const emit = defineEmits(['success']);
const formRef = ref();
const formData = ref<Partial<Demo02CategoryApi.Demo02Category>>({
id: undefined,
name: undefined,
parentId: undefined,
});
const rules: Record<string, Rule[]> = {
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
parentId: [{ required: true, message: '父级编号不能为空', trigger: 'blur' }],
};
const demo02CategoryTree = ref<any[]>([]); // 树形结构
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['示例分类'])
: $t('ui.actionTitle.create', ['示例分类']);
});
/** 重置表单 */
function resetForm() {
formData.value = {
id: undefined,
name: undefined,
parentId: undefined,
};
formRef.value?.resetFields();
}
/** 获得示例分类树 */
async function getDemo02CategoryTree() {
demo02CategoryTree.value = [];
const data = await getDemo02CategoryList({});
data.unshift({
id: 0,
name: '顶级示例分类',
});
demo02CategoryTree.value = handleTree(data);
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
await formRef.value?.validate();
modalApi.lock();
// 提交表单
const data = formData.value as Demo02CategoryApi.Demo02Category;
try {
await (formData.value?.id
? updateDemo02Category(data)
: createDemo02Category(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetForm();
return;
}
// 加载数据
let data = modalApi.getData<Demo02CategoryApi.Demo02Category>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo02Category(data.id);
} finally {
modalApi.unlock();
}
}
formData.value = data;
// 加载树数据
await getDemo02CategoryTree();
},
});
</script>
<template>
<Modal :title="getTitle">
<Form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="名字" name="name">
<Input v-model="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="父级编号" name="parentId">
<TreeSelect
v-model="formData.parentId"
:tree-data="demo02CategoryTree"
:field-names="{
label: 'name',
value: 'id',
children: 'children',
}"
checkable
tree-default-expand-all
placeholder="请选择父级编号"
/>
</Form.Item>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,339 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { onMounted, reactive, ref } from 'vue';
import { ContentWrap, Page, useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { useTableToolbar, VbenVxeTableToolbar } from '@vben/plugins/vxe-table';
import {
cloneDeep,
downloadFileFromBlobPart,
formatDateTime,
isEmpty,
} from '@vben/utils';
import {
Button,
DateRangePicker,
Form,
Input,
Pagination,
Select,
Tabs,
} from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
deleteDemo03Student,
deleteDemo03StudentList,
exportDemo03Student,
getDemo03StudentPage,
} from '#/api/infra/demo/demo03/erp';
import { DictTag } from '#/components/dict-tag';
import { $t } from '#/locales';
import { getRangePickerDefaultProps } from '#/utils';
import Demo03CourseList from './modules/demo03-course-list.vue';
import Demo03GradeList from './modules/demo03-grade-list.vue';
import Demo03StudentForm from './modules/form.vue';
/** 子表的列表 */
const subTabsName = ref('demo03Course');
const selectDemo03Student = ref<Demo03StudentApi.Demo03Student>();
async function onCellClick({ row }: { row: Demo03StudentApi.Demo03Student }) {
selectDemo03Student.value = row;
}
const loading = ref(true); // 列表的加载中
const list = ref<Demo03StudentApi.Demo03Student[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
sex: undefined,
description: undefined,
createTime: undefined,
});
const queryFormRef = ref(); // 搜索的表单
const exportLoading = ref(false); // 导出的加载中
/** 查询列表 */
async function getList() {
loading.value = true;
try {
const params = cloneDeep(queryParams) as any;
if (params.createTime && Array.isArray(params.createTime)) {
params.createTime = (params.createTime as string[]).join(',');
}
const data = await getDemo03StudentPage(params);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1;
getList();
};
/** 重置按钮操作 */
function resetQuery() {
queryFormRef.value.resetFields();
handleQuery();
}
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo03StudentForm,
destroyOnClose: true,
});
/** 创建学生 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑学生 */
function handleEdit(row: Demo03StudentApi.Demo03Student) {
formModalApi.setData(row).open();
}
/** 删除学生 */
async function handleDelete(row: Demo03StudentApi.Demo03Student) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
});
try {
await deleteDemo03Student(row.id!);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.id]),
});
await getList();
} finally {
message.close(hideLoading);
}
}
/** 批量删除学生 */
async function handleDeleteBatch() {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting'),
duration: 0,
});
try {
await deleteDemo03StudentList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
await getList();
} finally {
message.close(hideLoading);
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: Demo03StudentApi.Demo03Student[];
}) {
checkedIds.value = records.map((item) => item.id)!;
}
/** 导出表格 */
async function handleExport() {
try {
exportLoading.value = true;
const data = await exportDemo03Student(queryParams);
downloadFileFromBlobPart({ fileName: '学生.xls', source: data });
} finally {
exportLoading.value = false;
}
}
/** 初始化 */
const { hiddenSearchBar, tableToolbarRef, tableRef } = useTableToolbar();
onMounted(() => {
getList();
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="getList" />
<ContentWrap v-if="!hiddenSearchBar">
<!-- 搜索工作栏 -->
<Form :model="queryParams" ref="queryFormRef" layout="inline">
<Form.Item label="名字" name="name">
<Input
v-model="queryParams.name"
placeholder="请输入名字"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="性别" name="sex">
<Select
v-model="queryParams.sex"
placeholder="请选择性别"
allow-clear
class="w-full"
>
<Select.Option
v-for="dict in getDictOptions(
DICT_TYPE.SYSTEM_USER_SEX,
'number',
)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="创建时间" name="createTime">
<DateRangePicker
v-model="queryParams.createTime"
v-bind="getRangePickerDefaultProps()"
class="w-full"
/>
</Form.Item>
<Form.Item>
<Button class="ml-2" @click="resetQuery"> 重置 </Button>
<Button class="ml-2" @click="handleQuery" theme="primary">
搜索
</Button>
</Form.Item>
</Form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap title="学生">
<template #extra>
<VbenVxeTableToolbar
ref="tableToolbarRef"
v-model:hidden-search="hiddenSearchBar"
>
<Button
class="ml-2"
theme="primary"
@click="handleCreate"
v-access:code="['infra:demo03-student:create']"
>
<IconifyIcon icon="lucide:plus" />
{{ $t('ui.actionTitle.create', ['学生']) }}
</Button>
<Button
theme="primary"
class="ml-2"
:loading="exportLoading"
@click="handleExport"
v-access:code="['infra:demo03-student:export']"
>
<IconifyIcon icon="lucide:download" />
{{ $t('ui.actionTitle.export') }}
</Button>
<Button
theme="primary"
danger
class="ml-2"
:disabled="isEmpty(checkedIds)"
@click="handleDeleteBatch"
v-access:code="['infra:demo03-student:delete']"
>
<IconifyIcon icon="lucide:trash-2" />
批量删除
</Button>
</VbenVxeTableToolbar>
</template>
<VxeTable
ref="tableRef"
:data="list"
@cell-click="onCellClick"
:row-config="{
keyField: 'id',
isHover: true,
isCurrent: true,
}"
show-overflow
:loading="loading"
@checkbox-all="handleRowCheckboxChange"
@checkbox-change="handleRowCheckboxChange"
>
<VxeColumn type="checkbox" width="40" />
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="name" title="名字" align="center" />
<VxeColumn field="sex" title="性别" align="center">
<template #default="{ row }">
<DictTag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="row.sex" />
</template>
</VxeColumn>
<VxeColumn field="birthday" title="出生日期" align="center">
<template #default="{ row }">
{{ formatDateTime(row.birthday) }}
</template>
</VxeColumn>
<VxeColumn field="description" title="简介" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
variant="text"
@click="handleEdit(row)"
v-access:code="['infra:demo03-student:update']"
>
{{ $t('ui.actionTitle.edit') }}
</Button>
<Button
size="small"
variant="text"
danger
class="ml-2"
@click="handleDelete(row)"
v-access:code="['infra:demo03-student:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
<!-- 分页 -->
<div class="mt-2 flex justify-end">
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
show-size-changer
@change="getList"
/>
</div>
</ContentWrap>
<ContentWrap>
<!-- 子表的表单 -->
<Tabs v-model:active-key="subTabsName">
<Tabs.TabPane key="demo03Course" tab="学生课程" force-render>
<Demo03CourseList :student-id="selectDemo03Student?.id" />
</Tabs.TabPane>
<Tabs.TabPane key="demo03Grade" tab="学生班级" force-render>
<Demo03GradeList :student-id="selectDemo03Student?.id" />
</Tabs.TabPane>
</Tabs>
</ContentWrap>
</Page>
</template>

View File

@@ -0,0 +1,117 @@
<script lang="ts" setup>
import type { FormRule } from 'tdesign-vue-next';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Form, Input } from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import {
createDemo03Course,
getDemo03Course,
updateDemo03Course,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
type Rule = FormRule;
const emit = defineEmits(['success']);
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['学生课程'])
: $t('ui.actionTitle.create', ['学生课程']);
});
const formRef = ref();
const formData = ref<Partial<Demo03StudentApi.Demo03Course>>({
id: undefined,
studentId: undefined,
name: undefined,
score: undefined,
});
const rules: Record<string, Rule[]> = {
studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }],
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
score: [{ required: true, message: '分数不能为空', trigger: 'blur' }],
};
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
await formRef.value?.validate();
modalApi.lock();
// 提交表单
const data = formData.value as Demo03StudentApi.Demo03Course;
try {
await (formData.value?.id
? updateDemo03Course(data)
: createDemo03Course(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetForm();
return;
}
// 加载数据
let data = modalApi.getData<Demo03StudentApi.Demo03Course>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Course(data.id);
} finally {
modalApi.unlock();
}
}
// 设置到 values
formData.value = data;
},
});
/** 重置表单 */
function resetForm() {
formData.value = {
id: undefined,
studentId: undefined,
name: undefined,
score: undefined,
};
formRef.value?.resetFields();
}
</script>
<template>
<Modal :title="getTitle">
<Form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="学生编号" name="studentId">
<Input v-model="formData.studentId" placeholder="请输入学生编号" />
</Form.Item>
<Form.Item label="名字" name="name">
<Input v-model="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="分数" name="score">
<Input v-model="formData.score" placeholder="请输入分数" />
</Form.Item>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,291 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { nextTick, onMounted, reactive, ref, watch } from 'vue';
import { ContentWrap, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { useTableToolbar, VbenVxeTableToolbar } from '@vben/plugins/vxe-table';
import { cloneDeep, formatDateTime, isEmpty } from '@vben/utils';
import {
Button,
DateRangePicker,
Form,
Input,
Pagination,
} from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
deleteDemo03Course,
deleteDemo03CourseList,
getDemo03CoursePage,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
import { getRangePickerDefaultProps } from '#/utils';
import Demo03CourseForm from './demo03-course-form.vue';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo03CourseForm,
destroyOnClose: true,
});
/** 创建学生课程 */
function handleCreate() {
if (!props.studentId) {
message.warning('请先选择一个学生!');
return;
}
formModalApi.setData({ studentId: props.studentId }).open();
}
/** 编辑学生课程 */
function handleEdit(row: Demo03StudentApi.Demo03Course) {
formModalApi.setData(row).open();
}
/** 删除学生课程 */
async function handleDelete(row: Demo03StudentApi.Demo03Course) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
});
try {
await deleteDemo03Course(row.id!);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.id]),
});
await getList();
} finally {
message.close(hideLoading);
}
}
/** 批量删除学生课程 */
async function handleDeleteBatch() {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting'),
duration: 0,
});
try {
await deleteDemo03CourseList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
await getList();
} finally {
message.close(hideLoading);
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: Demo03StudentApi.Demo03Course[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const loading = ref(true); // 列表的加载中
const list = ref<Demo03StudentApi.Demo03Course[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const queryFormRef = ref(); // 搜索的表单
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
studentId: undefined,
name: undefined,
score: undefined,
createTime: undefined,
});
/** 搜索按钮操作 */
function handleQuery() {
queryParams.pageNo = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
queryFormRef.value.resetFields();
handleQuery();
}
/** 查询列表 */
async function getList() {
loading.value = true;
try {
if (!props.studentId) {
return [];
}
const params = cloneDeep(queryParams) as any;
if (params.createTime && Array.isArray(params.createTime)) {
params.createTime = (params.createTime as string[]).join(',');
}
params.studentId = props.studentId;
const data = await getDemo03CoursePage(params);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
}
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await getList();
},
{ immediate: true },
);
/** 初始化 */
const { hiddenSearchBar, tableToolbarRef, tableRef } = useTableToolbar();
onMounted(() => {
getList();
});
</script>
<template>
<FormModal @success="getList" />
<div class="h-[600px]">
<ContentWrap v-if="!hiddenSearchBar">
<!-- 搜索工作栏 -->
<Form :model="queryParams" ref="queryFormRef" layout="inline">
<Form.Item label="学生编号" name="studentId">
<Input
v-model="queryParams.studentId"
placeholder="请输入学生编号"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="名字" name="name">
<Input
v-model="queryParams.name"
placeholder="请输入名字"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="分数" name="score">
<Input
v-model="queryParams.score"
placeholder="请输入分数"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="创建时间" name="createTime">
<DateRangePicker
v-model="queryParams.createTime"
v-bind="getRangePickerDefaultProps()"
class="w-full"
/>
</Form.Item>
<Form.Item>
<Button class="ml-2" @click="resetQuery"> 重置 </Button>
<Button class="ml-2" @click="handleQuery" theme="primary">
搜索
</Button>
</Form.Item>
</Form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap title="学生">
<template #extra>
<VbenVxeTableToolbar
ref="tableToolbarRef"
v-model:hidden-search="hiddenSearchBar"
>
<Button
class="ml-2"
theme="primary"
@click="handleCreate"
v-access:code="['infra:demo03-student:create']"
>
<IconifyIcon icon="lucide:plus" />
{{ $t('ui.actionTitle.create', ['学生']) }}
</Button>
<Button
theme="primary"
danger
class="ml-2"
:disabled="isEmpty(checkedIds)"
@click="handleDeleteBatch"
v-access:code="['infra:demo03-student:delete']"
>
<IconifyIcon icon="lucide:trash-2" />
批量删除
</Button>
</VbenVxeTableToolbar>
</template>
<VxeTable
ref="tableRef"
:data="list"
show-overflow
:loading="loading"
@checkbox-all="handleRowCheckboxChange"
@checkbox-change="handleRowCheckboxChange"
>
<VxeColumn type="checkbox" width="40" />
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="studentId" title="学生编号" align="center" />
<VxeColumn field="name" title="名字" align="center" />
<VxeColumn field="score" title="分数" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
variant="text"
@click="handleEdit(row)"
v-access:code="['infra:demo03-student:update']"
>
{{ $t('ui.actionTitle.edit') }}
</Button>
<Button
size="small"
variant="text"
danger
class="ml-2"
@click="handleDelete(row)"
v-access:code="['infra:demo03-student:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
<!-- 分页 -->
<div class="mt-2 flex justify-end">
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
show-size-changer
@change="getList"
/>
</div>
</ContentWrap>
</div>
</template>

View File

@@ -0,0 +1,117 @@
<script lang="ts" setup>
import type { FormRule } from 'tdesign-vue-next';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Form, Input } from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import {
createDemo03Grade,
getDemo03Grade,
updateDemo03Grade,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
type Rule = FormRule;
const emit = defineEmits(['success']);
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['学生班级'])
: $t('ui.actionTitle.create', ['学生班级']);
});
const formRef = ref();
const formData = ref<Partial<Demo03StudentApi.Demo03Grade>>({
id: undefined,
studentId: undefined,
name: undefined,
teacher: undefined,
});
const rules: Record<string, Rule[]> = {
studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }],
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
teacher: [{ required: true, message: '班主任不能为空', trigger: 'blur' }],
};
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
await formRef.value?.validate();
modalApi.lock();
// 提交表单
const data = formData.value as Demo03StudentApi.Demo03Grade;
try {
await (formData.value?.id
? updateDemo03Grade(data)
: createDemo03Grade(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetForm();
return;
}
// 加载数据
let data = modalApi.getData<Demo03StudentApi.Demo03Grade>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Grade(data.id);
} finally {
modalApi.unlock();
}
}
// 设置到 values
formData.value = data;
},
});
/** 重置表单 */
function resetForm() {
formData.value = {
id: undefined,
studentId: undefined,
name: undefined,
teacher: undefined,
};
formRef.value?.resetFields();
}
</script>
<template>
<Modal :title="getTitle">
<Form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="学生编号" name="studentId">
<Input v-model="formData.studentId" placeholder="请输入学生编号" />
</Form.Item>
<Form.Item label="名字" name="name">
<Input v-model="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="班主任" name="teacher">
<Input v-model="formData.teacher" placeholder="请输入班主任" />
</Form.Item>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,291 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { nextTick, onMounted, reactive, ref, watch } from 'vue';
import { ContentWrap, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { useTableToolbar, VbenVxeTableToolbar } from '@vben/plugins/vxe-table';
import { cloneDeep, formatDateTime, isEmpty } from '@vben/utils';
import {
Button,
DateRangePicker,
Form,
Input,
Pagination,
} from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
deleteDemo03Grade,
deleteDemo03GradeList,
getDemo03GradePage,
} from '#/api/infra/demo/demo03/erp';
import { $t } from '#/locales';
import { getRangePickerDefaultProps } from '#/utils';
import Demo03GradeForm from './demo03-grade-form.vue';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo03GradeForm,
destroyOnClose: true,
});
/** 创建学生班级 */
function handleCreate() {
if (!props.studentId) {
message.warning('请先选择一个学生!');
return;
}
formModalApi.setData({ studentId: props.studentId }).open();
}
/** 编辑学生班级 */
function handleEdit(row: Demo03StudentApi.Demo03Grade) {
formModalApi.setData(row).open();
}
/** 删除学生班级 */
async function handleDelete(row: Demo03StudentApi.Demo03Grade) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
});
try {
await deleteDemo03Grade(row.id!);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.id]),
});
await getList();
} finally {
message.close(hideLoading);
}
}
/** 批量删除学生班级 */
async function handleDeleteBatch() {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting'),
duration: 0,
});
try {
await deleteDemo03GradeList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
await getList();
} finally {
message.close(hideLoading);
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: Demo03StudentApi.Demo03Grade[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const loading = ref(true); // 列表的加载中
const list = ref<Demo03StudentApi.Demo03Grade[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const queryFormRef = ref(); // 搜索的表单
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
studentId: undefined,
name: undefined,
teacher: undefined,
createTime: undefined,
});
/** 搜索按钮操作 */
function handleQuery() {
queryParams.pageNo = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
queryFormRef.value.resetFields();
handleQuery();
}
/** 查询列表 */
async function getList() {
loading.value = true;
try {
if (!props.studentId) {
return [];
}
const params = cloneDeep(queryParams) as any;
if (params.createTime && Array.isArray(params.createTime)) {
params.createTime = (params.createTime as string[]).join(',');
}
params.studentId = props.studentId;
const data = await getDemo03GradePage(params);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
}
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await getList();
},
{ immediate: true },
);
/** 初始化 */
const { hiddenSearchBar, tableToolbarRef, tableRef } = useTableToolbar();
onMounted(() => {
getList();
});
</script>
<template>
<FormModal @success="getList" />
<div class="h-[600px]">
<ContentWrap v-if="!hiddenSearchBar">
<!-- 搜索工作栏 -->
<Form :model="queryParams" ref="queryFormRef" layout="inline">
<Form.Item label="学生编号" name="studentId">
<Input
v-model="queryParams.studentId"
placeholder="请输入学生编号"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="名字" name="name">
<Input
v-model="queryParams.name"
placeholder="请输入名字"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="班主任" name="teacher">
<Input
v-model="queryParams.teacher"
placeholder="请输入班主任"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="创建时间" name="createTime">
<DateRangePicker
v-model="queryParams.createTime"
v-bind="getRangePickerDefaultProps()"
class="w-full"
/>
</Form.Item>
<Form.Item>
<Button class="ml-2" @click="resetQuery"> 重置 </Button>
<Button class="ml-2" @click="handleQuery" theme="primary">
搜索
</Button>
</Form.Item>
</Form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap title="学生">
<template #extra>
<VbenVxeTableToolbar
ref="tableToolbarRef"
v-model:hidden-search="hiddenSearchBar"
>
<Button
class="ml-2"
theme="primary"
@click="handleCreate"
v-access:code="['infra:demo03-student:create']"
>
<IconifyIcon icon="lucide:plus" />
{{ $t('ui.actionTitle.create', ['学生']) }}
</Button>
<Button
theme="primary"
danger
class="ml-2"
:disabled="isEmpty(checkedIds)"
@click="handleDeleteBatch"
v-access:code="['infra:demo03-student:delete']"
>
<IconifyIcon icon="lucide:trash-2" />
批量删除
</Button>
</VbenVxeTableToolbar>
</template>
<VxeTable
ref="tableRef"
:data="list"
show-overflow
:loading="loading"
@checkbox-all="handleRowCheckboxChange"
@checkbox-change="handleRowCheckboxChange"
>
<VxeColumn type="checkbox" width="40" />
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="studentId" title="学生编号" align="center" />
<VxeColumn field="name" title="名字" align="center" />
<VxeColumn field="teacher" title="班主任" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
variant="text"
@click="handleEdit(row)"
v-access:code="['infra:demo03-student:update']"
>
{{ $t('ui.actionTitle.edit') }}
</Button>
<Button
size="small"
variant="text"
danger
class="ml-2"
@click="handleDelete(row)"
v-access:code="['infra:demo03-student:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
<!-- 分页 -->
<div class="mt-2 flex justify-end">
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
show-size-changer
@change="getList"
/>
</div>
</ContentWrap>
</div>
</template>

View File

@@ -0,0 +1,135 @@
<script lang="ts" setup>
import type { FormRule } from 'tdesign-vue-next';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/erp';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { DatePicker, Form, Input, Radio, RadioGroup } from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import {
createDemo03Student,
getDemo03Student,
updateDemo03Student,
} from '#/api/infra/demo/demo03/erp';
import { Tinymce as RichTextarea } from '#/components/tinymce';
import { $t } from '#/locales';
type Rule = FormRule;
const emit = defineEmits(['success']);
const formRef = ref();
const formData = ref<Partial<Demo03StudentApi.Demo03Student>>({
id: undefined,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
});
const rules: Record<string, Rule[]> = {
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
sex: [{ required: true, message: '性别不能为空', trigger: 'blur' }],
birthday: [{ required: true, message: '出生日期不能为空', trigger: 'blur' }],
description: [{ required: true, message: '简介不能为空', trigger: 'blur' }],
};
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['学生'])
: $t('ui.actionTitle.create', ['学生']);
});
/** 重置表单 */
function resetForm() {
formData.value = {
id: undefined,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
};
formRef.value?.resetFields();
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
await formRef.value?.validate();
modalApi.lock();
// 提交表单
const data = formData.value as Demo03StudentApi.Demo03Student;
try {
await (formData.value?.id
? updateDemo03Student(data)
: createDemo03Student(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetForm();
return;
}
// 加载数据
let data = modalApi.getData<Demo03StudentApi.Demo03Student>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Student(data.id);
} finally {
modalApi.unlock();
}
}
formData.value = data;
},
});
</script>
<template>
<Modal :title="getTitle">
<Form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="名字" name="name">
<Input v-model="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="性别" name="sex">
<RadioGroup v-model="formData.sex">
<Radio
v-for="dict in getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number')"
:key="dict.value.toString()"
:value="dict.value"
>
{{ dict.label }}
</Radio>
</RadioGroup>
</Form.Item>
<Form.Item label="出生日期" name="birthday">
<DatePicker
v-model="formData.birthday"
value-format="x"
placeholder="选择出生日期"
/>
</Form.Item>
<Form.Item label="简介" name="description">
<RichTextarea v-model="formData.description" height="500px" />
</Form.Item>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,331 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { onMounted, reactive, ref } from 'vue';
import { ContentWrap, Page, useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { useTableToolbar, VbenVxeTableToolbar } from '@vben/plugins/vxe-table';
import {
cloneDeep,
downloadFileFromBlobPart,
formatDateTime,
isEmpty,
} from '@vben/utils';
import {
Button,
DateRangePicker,
Form,
Input,
Pagination,
Select,
Tabs,
} from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
deleteDemo03Student,
deleteDemo03StudentList,
exportDemo03Student,
getDemo03StudentPage,
} from '#/api/infra/demo/demo03/normal';
import { DictTag } from '#/components/dict-tag';
import { $t } from '#/locales';
import { getRangePickerDefaultProps } from '#/utils';
import Demo03CourseList from './modules/demo03-course-list.vue';
import Demo03GradeList from './modules/demo03-grade-list.vue';
import Demo03StudentForm from './modules/form.vue';
/** 子表的列表 */
const subTabsName = ref('demo03Course');
const loading = ref(true); // 列表的加载中
const list = ref<Demo03StudentApi.Demo03Student[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
sex: undefined,
description: undefined,
createTime: undefined,
});
const queryFormRef = ref(); // 搜索的表单
const exportLoading = ref(false); // 导出的加载中
/** 查询列表 */
async function getList() {
loading.value = true;
try {
const params = cloneDeep(queryParams) as any;
if (params.createTime && Array.isArray(params.createTime)) {
params.createTime = (params.createTime as string[]).join(',');
}
const data = await getDemo03StudentPage(params);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.pageNo = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
queryFormRef.value.resetFields();
handleQuery();
}
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo03StudentForm,
destroyOnClose: true,
});
/** 创建学生 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑学生 */
function handleEdit(row: Demo03StudentApi.Demo03Student) {
formModalApi.setData(row).open();
}
/** 删除学生 */
async function handleDelete(row: Demo03StudentApi.Demo03Student) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
});
try {
await deleteDemo03Student(row.id!);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.id]),
});
await getList();
} finally {
message.close(hideLoading);
}
}
/** 批量删除学生 */
async function handleDeleteBatch() {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting'),
duration: 0,
});
try {
await deleteDemo03StudentList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
await getList();
} finally {
message.close(hideLoading);
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: Demo03StudentApi.Demo03Student[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
/** 导出表格 */
async function handleExport() {
try {
exportLoading.value = true;
const data = await exportDemo03Student(queryParams);
downloadFileFromBlobPart({ fileName: '学生.xls', source: data });
} finally {
exportLoading.value = false;
}
}
/** 初始化 */
const { hiddenSearchBar, tableToolbarRef, tableRef } = useTableToolbar();
onMounted(() => {
getList();
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="getList" />
<ContentWrap v-if="!hiddenSearchBar">
<!-- 搜索工作栏 -->
<Form :model="queryParams" ref="queryFormRef" layout="inline">
<Form.Item label="名字" name="name">
<Input
v-model="queryParams.name"
placeholder="请输入名字"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="性别" name="sex">
<Select
v-model="queryParams.sex"
placeholder="请选择性别"
allow-clear
class="w-full"
>
<Select.Option
v-for="dict in getDictOptions(
DICT_TYPE.SYSTEM_USER_SEX,
'number',
)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="创建时间" name="createTime">
<DateRangePicker
v-model="queryParams.createTime"
v-bind="getRangePickerDefaultProps()"
class="w-full"
/>
</Form.Item>
<Form.Item>
<Button class="ml-2" @click="resetQuery"> 重置 </Button>
<Button class="ml-2" @click="handleQuery" theme="primary">
搜索
</Button>
</Form.Item>
</Form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap title="学生">
<template #extra>
<VbenVxeTableToolbar
ref="tableToolbarRef"
v-model:hidden-search="hiddenSearchBar"
>
<Button
class="ml-2"
theme="primary"
@click="handleCreate"
v-access:code="['infra:demo03-student:create']"
>
<IconifyIcon icon="lucide:plus" />
{{ $t('ui.actionTitle.create', ['学生']) }}
</Button>
<Button
theme="primary"
class="ml-2"
:loading="exportLoading"
@click="handleExport"
v-access:code="['infra:demo03-student:export']"
>
<IconifyIcon icon="lucide:download" />
{{ $t('ui.actionTitle.export') }}
</Button>
<Button
theme="primary"
danger
class="ml-2"
:disabled="isEmpty(checkedIds)"
@click="handleDeleteBatch"
v-access:code="['infra:demo03-student:delete']"
>
<IconifyIcon icon="lucide:trash-2" />
批量删除
</Button>
</VbenVxeTableToolbar>
</template>
<VxeTable
ref="tableRef"
:data="list"
show-overflow
:loading="loading"
@checkbox-all="handleRowCheckboxChange"
@checkbox-change="handleRowCheckboxChange"
>
<VxeColumn type="checkbox" width="40" />
<!-- 子表的列表 -->
<VxeColumn type="expand" width="60">
<template #content="{ row }">
<!-- 子表的表单 -->
<Tabs v-model:active-key="subTabsName" class="mx-8">
<Tabs.TabPane key="demo03Course" tab="学生课程" force-render>
<Demo03CourseList :student-id="row?.id" />
</Tabs.TabPane>
<Tabs.TabPane key="demo03Grade" tab="学生班级" force-render>
<Demo03GradeList :student-id="row?.id" />
</Tabs.TabPane>
</Tabs>
</template>
</VxeColumn>
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="name" title="名字" align="center" />
<VxeColumn field="sex" title="性别" align="center">
<template #default="{ row }">
<DictTag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="row.sex" />
</template>
</VxeColumn>
<VxeColumn field="birthday" title="出生日期" align="center">
<template #default="{ row }">
{{ formatDateTime(row.birthday) }}
</template>
</VxeColumn>
<VxeColumn field="description" title="简介" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
variant="text"
@click="handleEdit(row)"
v-access:code="['infra:demo03-student:update']"
>
{{ $t('ui.actionTitle.edit') }}
</Button>
<Button
size="small"
variant="text"
danger
class="ml-2"
@click="handleDelete(row)"
v-access:code="['infra:demo03-student:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
<!-- 分页 -->
<div class="mt-2 flex justify-end">
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
show-size-changer
@change="getList"
/>
</div>
</ContentWrap>
</Page>
</template>

View File

@@ -0,0 +1,95 @@
<script lang="ts" setup>
import type { VxeTableInstance } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Button, Input } from 'tdesign-vue-next';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import { getDemo03CourseListByStudentId } from '#/api/infra/demo/demo03/normal';
import { $t } from '#/locales';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const list = ref<Demo03StudentApi.Demo03Course[]>([]); // 列表的数据
const tableRef = ref<VxeTableInstance>();
/** 添加学生课程 */
async function onAdd() {
await tableRef.value?.insertAt({} as Demo03StudentApi.Demo03Course, -1);
}
/** 删除学生课程 */
async function handleDelete(row: Demo03StudentApi.Demo03Course) {
await tableRef.value?.remove(row);
}
/** 提供获取表格数据的方法供父组件调用 */
defineExpose({
getData: (): Demo03StudentApi.Demo03Course[] => {
const data = list.value as Demo03StudentApi.Demo03Course[];
const removeRecords =
tableRef.value?.getRemoveRecords() as Demo03StudentApi.Demo03Course[];
const insertRecords =
tableRef.value?.getInsertRecords() as Demo03StudentApi.Demo03Course[];
return data
.filter((row) => !removeRecords.some((removed) => removed.id === row.id))
?.concat(insertRecords.map((row: any) => ({ ...row, id: undefined })));
},
});
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
list.value = await getDemo03CourseListByStudentId(props.studentId!);
},
{ immediate: true },
);
</script>
<template>
<VxeTable ref="tableRef" :data="list" show-overflow class="mx-4">
<VxeColumn field="name" title="名字" align="center">
<template #default="{ row }">
<Input v-model="row.name" />
</template>
</VxeColumn>
<VxeColumn field="score" title="分数" align="center">
<template #default="{ row }">
<Input v-model="row.score" />
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
variant="text"
danger
@click="handleDelete(row)"
v-access:code="['infra:demo03-student:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
<div class="mt-4 flex justify-center">
<Button
theme="primary"
ghost
@click="onAdd"
v-access:code="['infra:demo03-student:create']"
>
<IconifyIcon icon="lucide:plus" />
{{ $t('ui.actionTitle.create', ['学生课程']) }}
</Button>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { nextTick, ref, watch } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { formatDateTime } from '@vben/utils';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import { getDemo03CourseListByStudentId } from '#/api/infra/demo/demo03/normal';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const loading = ref(true); // 列表的加载中
const list = ref<Demo03StudentApi.Demo03Course[]>([]); // 列表的数据
/** 查询列表 */
async function getList() {
loading.value = true;
try {
if (!props.studentId) {
return [];
}
list.value = await getDemo03CourseListByStudentId(props.studentId!);
} finally {
loading.value = false;
}
}
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await getList();
},
{ immediate: true },
);
</script>
<template>
<ContentWrap title="学生课程列表">
<VxeTable :data="list" show-overflow :loading="loading">
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="studentId" title="学生编号" align="center" />
<VxeColumn field="name" title="名字" align="center" />
<VxeColumn field="score" title="分数" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
</VxeTable>
</ContentWrap>
</template>

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
import type { FormRule } from 'tdesign-vue-next';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { nextTick, ref, watch } from 'vue';
import { Form, Input } from 'tdesign-vue-next';
import { getDemo03GradeByStudentId } from '#/api/infra/demo/demo03/normal';
type Rule = FormRule;
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const formRef = ref();
const formData = ref<Partial<Demo03StudentApi.Demo03Grade>>({
id: undefined,
studentId: undefined,
name: undefined,
teacher: undefined,
});
const rules: Record<string, Rule[]> = {
studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }],
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
teacher: [{ required: true, message: '班主任不能为空', trigger: 'blur' }],
};
/** 暴露出表单校验方法和表单值获取方法 */
defineExpose({
validate: async () => await formRef.value?.validate(),
getValues: () => formData.value,
});
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
formData.value = await getDemo03GradeByStudentId(props.studentId!);
},
{ immediate: true },
);
</script>
<template>
<Form
ref="formRef"
class="mx-4"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="学生编号" name="studentId">
<Input v-model="formData.studentId" placeholder="请输入学生编号" />
</Form.Item>
<Form.Item label="名字" name="name">
<Input v-model="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="班主任" name="teacher">
<Input v-model="formData.teacher" placeholder="请输入班主任" />
</Form.Item>
</Form>
</template>

View File

@@ -0,0 +1,59 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { nextTick, ref, watch } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { formatDateTime } from '@vben/utils';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import { getDemo03GradeByStudentId } from '#/api/infra/demo/demo03/normal';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const loading = ref(true); // 列表的加载中
const list = ref<Demo03StudentApi.Demo03Grade[]>([]); // 列表的数据
/** 查询列表 */
async function getList() {
loading.value = true;
try {
if (!props.studentId) {
return [];
}
list.value = [await getDemo03GradeByStudentId(props.studentId!)];
} finally {
loading.value = false;
}
}
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
await getList();
},
{ immediate: true },
);
</script>
<template>
<ContentWrap title="学生班级列表">
<VxeTable :data="list" show-overflow :loading="loading">
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="studentId" title="学生编号" align="center" />
<VxeColumn field="name" title="名字" align="center" />
<VxeColumn field="teacher" title="班主任" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
</VxeTable>
</ContentWrap>
</template>

View File

@@ -0,0 +1,173 @@
<script lang="ts" setup>
import type { FormRule } from 'tdesign-vue-next';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import {
DatePicker,
Form,
Input,
Radio,
RadioGroup,
Tabs,
} from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import {
createDemo03Student,
getDemo03Student,
updateDemo03Student,
} from '#/api/infra/demo/demo03/normal';
import { Tinymce as RichTextarea } from '#/components/tinymce';
import { $t } from '#/locales';
import Demo03CourseForm from './demo03-course-form.vue';
import Demo03GradeForm from './demo03-grade-form.vue';
type Rule = FormRule;
const emit = defineEmits(['success']);
const formRef = ref();
const formData = ref<Partial<Demo03StudentApi.Demo03Student>>({
id: undefined,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
});
const rules: Record<string, Rule[]> = {
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
sex: [{ required: true, message: '性别不能为空', trigger: 'blur' }],
birthday: [{ required: true, message: '出生日期不能为空', trigger: 'blur' }],
description: [{ required: true, message: '简介不能为空', trigger: 'blur' }],
};
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['学生'])
: $t('ui.actionTitle.create', ['学生']);
});
/** 子表的表单 */
const subTabsName = ref('demo03Course');
const demo03CourseFormRef = ref<InstanceType<typeof Demo03CourseForm>>();
const demo03GradeFormRef = ref<InstanceType<typeof Demo03GradeForm>>();
/** 重置表单 */
function resetForm() {
formData.value = {
id: undefined,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
};
formRef.value?.resetFields();
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
await formRef.value?.validate();
// 校验子表单
try {
await demo03GradeFormRef.value?.validate();
} catch {
subTabsName.value = 'demo03Grade';
return;
}
modalApi.lock();
// 提交表单
const data = formData.value as Demo03StudentApi.Demo03Student;
// 拼接子表的数据
data.demo03courses = demo03CourseFormRef.value?.getData();
data.demo03grade =
demo03GradeFormRef.value?.getValues() as Demo03StudentApi.Demo03Grade;
try {
await (formData.value?.id
? updateDemo03Student(data)
: createDemo03Student(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetForm();
return;
}
// 加载数据
let data = modalApi.getData<Demo03StudentApi.Demo03Student>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Student(data.id);
} finally {
modalApi.unlock();
}
}
formData.value = data;
},
});
</script>
<template>
<Modal :title="getTitle">
<Form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="名字" name="name">
<Input v-model="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="性别" name="sex">
<RadioGroup v-model="formData.sex">
<Radio
v-for="dict in getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number')"
:key="dict.value.toString()"
:value="dict.value"
>
{{ dict.label }}
</Radio>
</RadioGroup>
</Form.Item>
<Form.Item label="出生日期" name="birthday">
<DatePicker
v-model="formData.birthday"
value-format="x"
placeholder="选择出生日期"
/>
</Form.Item>
<Form.Item label="简介" name="description">
<RichTextarea v-model="formData.description" height="500px" />
</Form.Item>
</Form>
<!-- 子表的表单 -->
<Tabs v-model:active-key="subTabsName">
<Tabs.TabPane key="demo03Course" tab="学生课程" force-render>
<Demo03CourseForm
ref="demo03CourseFormRef"
:student-id="formData?.id"
/>
</Tabs.TabPane>
<Tabs.TabPane key="demo03Grade" tab="学生班级" force-render>
<Demo03GradeForm ref="demo03GradeFormRef" :student-id="formData?.id" />
</Tabs.TabPane>
</Tabs>
</Modal>
</template>

View File

@@ -0,0 +1,311 @@
<script lang="ts" setup>
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { onMounted, reactive, ref } from 'vue';
import { ContentWrap, Page, useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { useTableToolbar, VbenVxeTableToolbar } from '@vben/plugins/vxe-table';
import {
cloneDeep,
downloadFileFromBlobPart,
formatDateTime,
isEmpty,
} from '@vben/utils';
import {
Button,
DateRangePicker,
Form,
Input,
Pagination,
Select,
} from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
deleteDemo03Student,
deleteDemo03StudentList,
exportDemo03Student,
getDemo03StudentPage,
} from '#/api/infra/demo/demo03/normal';
import { DictTag } from '#/components/dict-tag';
import { $t } from '#/locales';
import { getRangePickerDefaultProps } from '#/utils';
import Demo03StudentForm from './modules/form.vue';
const loading = ref(true); // 列表的加载中
const list = ref<Demo03StudentApi.Demo03Student[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
sex: undefined,
description: undefined,
createTime: undefined,
});
const queryFormRef = ref(); // 搜索的表单
const exportLoading = ref(false); // 导出的加载中
/** 查询列表 */
async function getList() {
loading.value = true;
try {
const params = cloneDeep(queryParams) as any;
if (params.createTime && Array.isArray(params.createTime)) {
params.createTime = (params.createTime as string[]).join(',');
}
const data = await getDemo03StudentPage(params);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.pageNo = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
queryFormRef.value.resetFields();
handleQuery();
}
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Demo03StudentForm,
destroyOnClose: true,
});
/** 创建学生 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑学生 */
function handle(row: Demo03StudentApi.Demo03Student) {
formModalApi.setData(row).open();
}
/** 删除学生 */
async function handleDelete(row: Demo03StudentApi.Demo03Student) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.id]),
duration: 0,
});
try {
await deleteDemo03Student(row.id!);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.id]),
});
await getList();
} finally {
message.close(hideLoading);
}
}
/** 批量删除学生 */
async function handleDeleteBatch() {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting'),
duration: 0,
});
try {
await deleteDemo03StudentList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
await getList();
} finally {
message.close(hideLoading);
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: Demo03StudentApi.Demo03Student[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
/** 导出表格 */
async function handleExport() {
try {
exportLoading.value = true;
const data = await exportDemo03Student(queryParams);
downloadFileFromBlobPart({ fileName: '学生.xls', source: data });
} finally {
exportLoading.value = false;
}
}
/** 初始化 */
const { hiddenSearchBar, tableToolbarRef, tableRef } = useTableToolbar();
onMounted(() => {
getList();
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="getList" />
<ContentWrap v-if="!hiddenSearchBar">
<!-- 搜索工作栏 -->
<Form :model="queryParams" ref="queryFormRef" layout="inline">
<Form.Item label="名字" name="name">
<Input
v-model="queryParams.name"
placeholder="请输入名字"
allow-clear
@press-enter="handleQuery"
class="w-full"
/>
</Form.Item>
<Form.Item label="性别" name="sex">
<Select
v-model="queryParams.sex"
placeholder="请选择性别"
allow-clear
class="w-full"
>
<Select.Option
v-for="dict in getDictOptions(
DICT_TYPE.SYSTEM_USER_SEX,
'number',
)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="创建时间" name="createTime">
<DateRangePicker
v-model="queryParams.createTime"
v-bind="getRangePickerDefaultProps()"
class="w-full"
/>
</Form.Item>
<Form.Item>
<Button class="ml-2" @click="resetQuery"> 重置 </Button>
<Button class="ml-2" @click="handleQuery" theme="primary">
搜索
</Button>
</Form.Item>
</Form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap title="学生">
<template #extra>
<VbenVxeTableToolbar
ref="tableToolbarRef"
v-model:hidden-search="hiddenSearchBar"
>
<Button
class="ml-2"
theme="primary"
@click="handleCreate"
v-access:code="['infra:demo03-student:create']"
>
<IconifyIcon icon="lucide:plus" />
{{ $t('ui.actionTitle.create', ['学生']) }}
</Button>
<Button
theme="primary"
class="ml-2"
:loading="exportLoading"
@click="handleExport"
v-access:code="['infra:demo03-student:export']"
>
<IconifyIcon icon="lucide:download" />
{{ $t('ui.actionTitle.export') }}
</Button>
<Button
theme="primary"
danger
class="ml-2"
:disabled="isEmpty(checkedIds)"
@click="handleDeleteBatch"
v-access:code="['infra:demo03-student:delete']"
>
<IconifyIcon icon="lucide:trash-2" />
批量删除
</Button>
</VbenVxeTableToolbar>
</template>
<VxeTable
ref="tableRef"
:data="list"
show-overflow
:loading="loading"
@checkbox-all="handleRowCheckboxChange"
@checkbox-change="handleRowCheckboxChange"
>
<VxeColumn type="checkbox" width="40" />
<VxeColumn field="id" title="编号" align="center" />
<VxeColumn field="name" title="名字" align="center" />
<VxeColumn field="sex" title="性别" align="center">
<template #default="{ row }">
<DictTag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="row.sex" />
</template>
</VxeColumn>
<VxeColumn field="birthday" title="出生日期" align="center">
<template #default="{ row }">
{{ formatDateTime(row.birthday) }}
</template>
</VxeColumn>
<VxeColumn field="description" title="简介" align="center" />
<VxeColumn field="createTime" title="创建时间" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
variant="text"
@click="handle(row)"
v-access:code="['infra:demo03-student:update']"
>
{{ $t('ui.actionTitle.edit') }}
</Button>
<Button
size="small"
variant="text"
danger
class="ml-2"
@click="handleDelete(row)"
v-access:code="['infra:demo03-student:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
<!-- 分页 -->
<div class="mt-2 flex justify-end">
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
show-size-changer
@change="getList"
/>
</div>
</ContentWrap>
</Page>
</template>

View File

@@ -0,0 +1,95 @@
<script lang="ts" setup>
import type { VxeTableInstance } from '#/adapter/vxe-table';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Button, Input } from 'tdesign-vue-next';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import { getDemo03CourseListByStudentId } from '#/api/infra/demo/demo03/normal';
import { $t } from '#/locales';
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const list = ref<Demo03StudentApi.Demo03Course[]>([]); // 列表的数据
const tableRef = ref<VxeTableInstance>();
/** 添加学生课程 */
async function onAdd() {
await tableRef.value?.insertAt({} as Demo03StudentApi.Demo03Course, -1);
}
/** 删除学生课程 */
async function handleDelete(row: Demo03StudentApi.Demo03Course) {
await tableRef.value?.remove(row);
}
/** 提供获取表格数据的方法供父组件调用 */
defineExpose({
getData: (): Demo03StudentApi.Demo03Course[] => {
const data = list.value as Demo03StudentApi.Demo03Course[];
const removeRecords =
tableRef.value?.getRemoveRecords() as Demo03StudentApi.Demo03Course[];
const insertRecords =
tableRef.value?.getInsertRecords() as Demo03StudentApi.Demo03Course[];
return data
.filter((row) => !removeRecords.some((removed) => removed.id === row.id))
?.concat(insertRecords.map((row: any) => ({ ...row, id: undefined })));
},
});
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
list.value = await getDemo03CourseListByStudentId(props.studentId!);
},
{ immediate: true },
);
</script>
<template>
<VxeTable ref="tableRef" :data="list" show-overflow class="mx-4">
<VxeColumn field="name" title="名字" align="center">
<template #default="{ row }">
<Input v-model="row.name" />
</template>
</VxeColumn>
<VxeColumn field="score" title="分数" align="center">
<template #default="{ row }">
<Input v-model="row.score" />
</template>
</VxeColumn>
<VxeColumn field="operation" title="操作" align="center">
<template #default="{ row }">
<Button
size="small"
variant="text"
danger
@click="handleDelete(row)"
v-access:code="['infra:demo03-student:delete']"
>
{{ $t('ui.actionTitle.delete') }}
</Button>
</template>
</VxeColumn>
</VxeTable>
<div class="mt-4 flex justify-center">
<Button
theme="primary"
ghost
@click="onAdd"
v-access:code="['infra:demo03-student:create']"
>
<IconifyIcon icon="lucide:plus" />
{{ $t('ui.actionTitle.create', ['学生课程']) }}
</Button>
</div>
</template>

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
import type { FormRule } from 'tdesign-vue-next';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { nextTick, ref, watch } from 'vue';
import { Form, Input } from 'tdesign-vue-next';
import { getDemo03GradeByStudentId } from '#/api/infra/demo/demo03/normal';
type Rule = FormRule;
const props = defineProps<{
studentId?: number; // 学生编号(主表的关联字段)
}>();
const formRef = ref();
const formData = ref<Partial<Demo03StudentApi.Demo03Grade>>({
id: undefined,
studentId: undefined,
name: undefined,
teacher: undefined,
});
const rules: Record<string, Rule[]> = {
studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }],
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
teacher: [{ required: true, message: '班主任不能为空', trigger: 'blur' }],
};
/** 暴露出表单校验方法和表单值获取方法 */
defineExpose({
validate: async () => await formRef.value?.validate(),
getValues: () => formData.value,
});
/** 监听主表的关联字段的变化,加载对应的子表数据 */
watch(
() => props.studentId,
async (val) => {
if (!val) {
return;
}
await nextTick();
formData.value = await getDemo03GradeByStudentId(props.studentId!);
},
{ immediate: true },
);
</script>
<template>
<Form
ref="formRef"
class="mx-4"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="学生编号" name="studentId">
<Input v-model="formData.studentId" placeholder="请输入学生编号" />
</Form.Item>
<Form.Item label="名字" name="name">
<Input v-model="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="班主任" name="teacher">
<Input v-model="formData.teacher" placeholder="请输入班主任" />
</Form.Item>
</Form>
</template>

View File

@@ -0,0 +1,172 @@
<script lang="ts" setup>
import type { FormRule } from 'tdesign-vue-next';
import type { Demo03StudentApi } from '#/api/infra/demo/demo03/normal';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import {
DatePicker,
Form,
Input,
Radio,
RadioGroup,
Tabs,
} from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import {
createDemo03Student,
getDemo03Student,
updateDemo03Student,
} from '#/api/infra/demo/demo03/normal';
import { Tinymce as RichTextarea } from '#/components/tinymce';
import { $t } from '#/locales';
import Demo03CourseForm from './demo03-course-form.vue';
import Demo03GradeForm from './demo03-grade-form.vue';
type Rule = FormRule;
const emit = defineEmits(['success']);
const formRef = ref();
const formData = ref<Partial<Demo03StudentApi.Demo03Student>>({
id: undefined,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
});
const rules: Record<string, Rule[]> = {
name: [{ required: true, message: '名字不能为空', trigger: 'blur' }],
sex: [{ required: true, message: '性别不能为空', trigger: 'blur' }],
birthday: [{ required: true, message: '出生日期不能为空', trigger: 'blur' }],
description: [{ required: true, message: '简介不能为空', trigger: 'blur' }],
};
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['学生'])
: $t('ui.actionTitle.create', ['学生']);
});
/** 子表的表单 */
const subTabsName = ref('demo03Course');
const demo03CourseFormRef = ref<InstanceType<typeof Demo03CourseForm>>();
const demo03GradeFormRef = ref<InstanceType<typeof Demo03GradeForm>>();
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
sex: undefined,
birthday: undefined,
description: undefined,
};
formRef.value?.resetFields();
};
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
await formRef.value?.validate();
// 校验子表单
try {
await demo03GradeFormRef.value?.validate();
} catch {
subTabsName.value = 'demo03Grade';
return;
}
modalApi.lock();
// 提交表单
const data = formData.value as Demo03StudentApi.Demo03Student;
// 拼接子表的数据
data.demo03courses = demo03CourseFormRef.value?.getData();
data.demo03grade = demo03GradeFormRef.value?.getValues() as any;
try {
await (formData.value?.id
? updateDemo03Student(data)
: createDemo03Student(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetForm();
return;
}
// 加载数据
let data = modalApi.getData<Demo03StudentApi.Demo03Student>();
if (!data) {
return;
}
if (data.id) {
modalApi.lock();
try {
data = await getDemo03Student(data.id);
} finally {
modalApi.unlock();
}
}
formData.value = data;
},
});
</script>
<template>
<Modal :title="getTitle">
<Form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item label="名字" name="name">
<Input v-model="formData.name" placeholder="请输入名字" />
</Form.Item>
<Form.Item label="性别" name="sex">
<RadioGroup v-model="formData.sex">
<Radio
v-for="dict in getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number')"
:key="dict.value.toString()"
:value="dict.value"
>
{{ dict.label }}
</Radio>
</RadioGroup>
</Form.Item>
<Form.Item label="出生日期" name="birthday">
<DatePicker
v-model="formData.birthday"
value-format="x"
placeholder="选择出生日期"
/>
</Form.Item>
<Form.Item label="简介" name="description">
<RichTextarea v-model="formData.description" height="500px" />
</Form.Item>
</Form>
<!-- 子表的表单 -->
<Tabs v-model:active-key="subTabsName">
<Tabs.TabPane key="demo03Course" tab="学生课程" force-render>
<Demo03CourseForm
ref="demo03CourseFormRef"
:student-id="formData?.id"
/>
</Tabs.TabPane>
<Tabs.TabPane key="demo03Grade" tab="学生班级" force-render>
<Demo03GradeForm ref="demo03GradeFormRef" :student-id="formData?.id" />
</Tabs.TabPane>
</Tabs>
</Modal>
</template>

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { DocAlert, IFrame, Page } from '@vben/common-ui';
import { getConfigKey } from '#/api/infra/config';
const loading = ref(true); // 是否加载中
const src = ref(`${import.meta.env.VITE_BASE_URL}/druid/index.html`);
/** 初始化 */
onMounted(async () => {
try {
const data = await getConfigKey('url.druid');
if (data && data.length > 0) {
src.value = data;
}
} finally {
loading.value = false;
}
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="数据库 MyBatis" url="https://doc.iocoder.cn/mybatis/" />
<DocAlert
title="多数据源(读写分离)"
url="https://doc.iocoder.cn/dynamic-datasource/"
/>
</template>
<IFrame v-if="!loading" v-loading="loading" :src="src" />
</Page>
</template>

View File

@@ -0,0 +1,107 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { getRangePickerDefaultProps } from '#/utils';
/** 表单的字段 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'file',
label: '文件上传',
component: 'Upload',
componentProps: {
placeholder: '请选择要上传的文件',
},
rules: 'required',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'path',
label: '文件路径',
component: 'Input',
componentProps: {
placeholder: '请输入文件路径',
clearable: true,
},
},
{
fieldName: 'type',
label: '文件类型',
component: 'Input',
componentProps: {
placeholder: '请输入文件类型',
clearable: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'name',
title: '文件名',
minWidth: 150,
},
{
field: 'path',
title: '文件路径',
minWidth: 200,
showOverflow: true,
},
{
field: 'url',
title: 'URL',
minWidth: 200,
showOverflow: true,
},
{
field: 'size',
title: '文件大小',
minWidth: 80,
formatter: 'formatFileSize',
},
{
field: 'type',
title: '文件类型',
minWidth: 120,
},
{
field: 'file-content',
title: '文件内容',
minWidth: 120,
slots: {
default: 'file-content',
},
},
{
field: 'createTime',
title: '上传时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 160,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,188 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraFileApi } from '#/api/infra/file';
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { isEmpty, openWindow } from '@vben/utils';
import { useClipboard } from '@vueuse/core';
import { Button } from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteFile, deleteFileList, getFilePage } from '#/api/infra/file';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 上传文件 */
function handleUpload() {
formModalApi.setData(null).open();
}
/** 复制链接到剪贴板 */
const { copy } = useClipboard({ legacy: true });
async function handleCopyUrl(row: InfraFileApi.File) {
if (!row.url) {
message.error('文件 URL 为空');
return;
}
try {
await copy(row.url);
message.success('复制成功');
} catch {
message.error('复制失败');
}
}
/** 删除文件 */
async function handleDelete(row: InfraFileApi.File) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name || row.path]),
duration: 0,
});
try {
await deleteFile(row.id!);
message.success(
$t('ui.actionMessage.deleteSuccess', [row.name || row.path]),
);
handleRefresh();
} finally {
message.close(hideLoading);
}
}
/** 批量删除文件 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading({
content: $t('ui.actionMessage.deletingBatch'),
duration: 0,
});
try {
await deleteFileList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
message.close(hideLoading);
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: InfraFileApi.File[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getFilePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<InfraFileApi.File>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<Grid table-title="文件列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: '上传文件',
type: 'primary',
icon: ACTION_ICON.UPLOAD,
onClick: handleUpload,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'danger',
icon: ACTION_ICON.DELETE,
disabled: isEmpty(checkedIds),
auth: ['infra:file:delete'],
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #file-content="{ row }">
<ImageViewer
v-if="row.type && row.type.includes('image')"
:images="[row.url]"
/>
<Button v-else variant="text" @click="() => openWindow(row.url!)">
{{ row.type && row.type.includes('pdf') ? '预览' : '下载' }}
</Button>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '复制链接',
variant: 'text',
icon: ACTION_ICON.COPY,
onClick: handleCopyUrl.bind(null, row),
},
{
label: $t('common.delete'),
variant: 'text',
type: 'danger',
icon: ACTION_ICON.DELETE,
auth: ['infra:file:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
import { useVbenModal } from '@vben/common-ui';
import { Upload } from 'tdesign-vue-next';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/tdesign';
import { useUpload } from '#/components/upload/use-upload';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
hideLabel: true,
},
layout: 'horizontal',
schema: useFormSchema().map((item) => ({ ...item, label: '' })), // 去除label
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = await formApi.getValues();
try {
await useUpload().httpRequest(data.file);
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
});
/** 上传前 */
function beforeUpload(file: File) {
formApi.setFieldValue('file', file);
return false;
}
</script>
<template>
<Modal title="上传图片">
<Form class="mx-4">
<template #file>
<div class="w-full">
<!-- 上传区域 -->
<Upload.Dragger
name="file"
:max-count="1"
accept=".jpg,.png,.gif,.webp"
:before-upload="beforeUpload"
list-type="picture-card"
>
<p class="ant-upload-drag-icon">
<span class="icon-[ant-design--inbox-outlined] text-2xl"></span>
</p>
<p class="ant-upload-text">点击或拖拽文件到此区域上传</p>
<p class="ant-upload-hint">
支持 .jpg.png.gif.webp 格式图片文件
</p>
</Upload.Dragger>
</div>
</template>
</Form>
</Modal>
</template>

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