feat: add views

This commit is contained in:
xingyu4j
2025-11-11 15:24:41 +08:00
parent 10f2583e2f
commit 736d91019e
206 changed files with 30261 additions and 0 deletions

View File

@@ -0,0 +1,966 @@
<script lang="ts" setup>
import type { PropType } from 'vue';
import type { CronData, CronValue, ShortcutsType } from './types';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import {
Button,
Dialog,
Form,
Input,
InputNumber,
RadioButton,
RadioGroup,
Select,
Tabs,
} from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import { CronDataDefault, CronValueDefault } from './types';
defineOptions({ name: 'Crontab' });
const props = defineProps({
modelValue: {
type: String,
default: '* * * * * ?',
},
shortcuts: {
type: Array as PropType<ShortcutsType[]>,
default: () => [],
},
});
const emit = defineEmits(['update:modelValue']);
const defaultValue = ref('');
const dialogVisible = ref(false);
const cronValue = reactive<CronValue>(CronValueDefault);
const data = reactive<CronData>(CronDataDefault);
const value_second = computed(() => {
const v = cronValue.second;
switch (v.type) {
case '0': {
return '*';
}
case '1': {
return `${v.range.start}-${v.range.end}`;
}
case '2': {
return `${v.loop.start}/${v.loop.end}`;
}
case '3': {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
}
default: {
return '*';
}
}
});
const value_minute = computed(() => {
const v = cronValue.minute;
switch (v.type) {
case '0': {
return '*';
}
case '1': {
return `${v.range.start}-${v.range.end}`;
}
case '2': {
return `${v.loop.start}/${v.loop.end}`;
}
case '3': {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
}
default: {
return '*';
}
}
});
const value_hour = computed(() => {
const v = cronValue.hour;
switch (v.type) {
case '0': {
return '*';
}
case '1': {
return `${v.range.start}-${v.range.end}`;
}
case '2': {
return `${v.loop.start}/${v.loop.end}`;
}
case '3': {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
}
default: {
return '*';
}
}
});
const value_day = computed(() => {
const v = cronValue.day;
switch (v.type) {
case '0': {
return '*';
}
case '1': {
return `${v.range.start}-${v.range.end}`;
}
case '2': {
return `${v.loop.start}/${v.loop.end}`;
}
case '3': {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
}
case '4': {
return 'L';
}
case '5': {
return '?';
}
default: {
return '*';
}
}
});
const value_month = computed(() => {
const v = cronValue.month;
switch (v.type) {
case '0': {
return '*';
}
case '1': {
return `${v.range.start}-${v.range.end}`;
}
case '2': {
return `${v.loop.start}/${v.loop.end}`;
}
case '3': {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
}
default: {
return '*';
}
}
});
const value_week = computed(() => {
const v = cronValue.week;
switch (v.type) {
case '0': {
return '*';
}
case '1': {
return `${v.range.start}-${v.range.end}`;
}
case '2': {
return `${v.loop.end}#${v.loop.start}`;
}
case '3': {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
}
case '4': {
return `${v.last}L`;
}
case '5': {
return '?';
}
default: {
return '*';
}
}
});
const value_year = computed(() => {
const v = cronValue.year;
switch (v.type) {
case '-1': {
return '';
}
case '0': {
return '*';
}
case '1': {
return `${v.range.start}-${v.range.end}`;
}
case '2': {
return `${v.loop.start}/${v.loop.end}`;
}
case '3': {
return v.appoint.length > 0 ? v.appoint.join(',') : '';
}
default: {
return '';
}
}
});
watch(
() => cronValue.week.type,
(val: string) => {
if (val !== '5') {
cronValue.day.type = '5';
}
},
);
watch(
() => cronValue.day.type,
(val: string) => {
if (val !== '5') {
cronValue.week.type = '5';
}
},
);
watch(
() => props.modelValue,
() => {
defaultValue.value = props.modelValue;
},
);
onMounted(() => {
defaultValue.value = props.modelValue;
});
const select = ref<string>();
watch(
() => select.value,
() => {
if (select.value === 'custom') {
open();
} else {
defaultValue.value = select.value || '';
emit('update:modelValue', defaultValue.value);
}
},
);
function open() {
set();
dialogVisible.value = true;
}
function set() {
defaultValue.value = props.modelValue;
let arr = (props.modelValue || '* * * * * ?').split(' ');
/** 简单检查 */
if (arr.length < 6) {
message.warning('cron表达式错误已转换为默认表达式');
arr = '* * * * * ?'.split(' ');
}
/** 秒 */
if (arr[0] === '*') {
cronValue.second.type = '0';
} else if (arr[0]?.includes('-')) {
cronValue.second.type = '1';
cronValue.second.range.start = Number(arr[0].split('-')[0]);
cronValue.second.range.end = Number(arr[0].split('-')[1]);
} else if (arr[0]?.includes('/')) {
cronValue.second.type = '2';
cronValue.second.loop.start = Number(arr[0].split('/')[0]);
cronValue.second.loop.end = Number(arr[0].split('/')[1]);
} else {
cronValue.second.type = '3';
cronValue.second.appoint = arr[0]?.split(',') || [];
}
/** 分 */
if (arr[1] === '*') {
cronValue.minute.type = '0';
} else if (arr[1]?.includes('-')) {
cronValue.minute.type = '1';
cronValue.minute.range.start = Number(arr[1].split('-')[0]);
cronValue.minute.range.end = Number(arr[1].split('-')[1]);
} else if (arr[1]?.includes('/')) {
cronValue.minute.type = '2';
cronValue.minute.loop.start = Number(arr[1].split('/')[0]);
cronValue.minute.loop.end = Number(arr[1].split('/')[1]);
} else {
cronValue.minute.type = '3';
cronValue.minute.appoint = arr[1]?.split(',') || [];
}
/** 小时 */
if (arr[2] === '*') {
cronValue.hour.type = '0';
} else if (arr[2]?.includes('-')) {
cronValue.hour.type = '1';
cronValue.hour.range.start = Number(arr[2].split('-')[0]);
cronValue.hour.range.end = Number(arr[2].split('-')[1]);
} else if (arr[2]?.includes('/')) {
cronValue.hour.type = '2';
cronValue.hour.loop.start = Number(arr[2].split('/')[0]);
cronValue.hour.loop.end = Number(arr[2].split('/')[1]);
} else {
cronValue.hour.type = '3';
cronValue.hour.appoint = arr[2]?.split(',') || [];
}
/** 日 */
switch (arr[3]) {
case '*': {
cronValue.day.type = '0';
break;
}
case '?': {
cronValue.day.type = '5';
break;
}
case 'L': {
cronValue.day.type = '4';
break;
}
default: {
if (arr[3]?.includes('-')) {
cronValue.day.type = '1';
cronValue.day.range.start = Number(arr[3].split('-')[0]);
cronValue.day.range.end = Number(arr[3].split('-')[1]);
} else if (arr[3]?.includes('/')) {
cronValue.day.type = '2';
cronValue.day.loop.start = Number(arr[3].split('/')[0]);
cronValue.day.loop.end = Number(arr[3].split('/')[1]);
} else {
cronValue.day.type = '3';
cronValue.day.appoint = arr[3]?.split(',') || [];
}
}
}
/** 月 */
if (arr[4] === '*') {
cronValue.month.type = '0';
} else if (arr[4]?.includes('-')) {
cronValue.month.type = '1';
cronValue.month.range.start = Number(arr[4].split('-')[0]);
cronValue.month.range.end = Number(arr[4].split('-')[1]);
} else if (arr[4]?.includes('/')) {
cronValue.month.type = '2';
cronValue.month.loop.start = Number(arr[4].split('/')[0]);
cronValue.month.loop.end = Number(arr[4].split('/')[1]);
} else {
cronValue.month.type = '3';
cronValue.month.appoint = arr[4]?.split(',') || [];
}
/** 周 */
if (arr[5] === '*') {
cronValue.week.type = '0';
} else if (arr[5] === '?') {
cronValue.week.type = '5';
} else if (arr[5]?.includes('-')) {
cronValue.week.type = '1';
cronValue.week.range.start = arr[5].split('-')[0] || '';
cronValue.week.range.end = arr[5].split('-')[1] || '';
} else if (arr[5]?.includes('#')) {
cronValue.week.type = '2';
cronValue.week.loop.start = Number(arr[5].split('#')[1]);
cronValue.week.loop.end = arr[5].split('#')[0] || '';
} else if (arr[5]?.includes('L')) {
cronValue.week.type = '4';
cronValue.week.last = arr[5].split('L')[0] || '';
} else {
cronValue.week.type = '3';
cronValue.week.appoint = arr[5]?.split(',') || [];
}
/** 年 */
if (!arr[6]) {
cronValue.year.type = '-1';
} else if (arr[6] === '*') {
cronValue.year.type = '0';
} else if (arr[6]?.includes('-')) {
cronValue.year.type = '1';
cronValue.year.range.start = Number(arr[6].split('-')[0]);
cronValue.year.range.end = Number(arr[6].split('-')[1]);
} else if (arr[6]?.includes('/')) {
cronValue.year.type = '2';
cronValue.year.loop.start = Number(arr[6].split('/')[1]);
cronValue.year.loop.end = Number(arr[6].split('/')[0]);
} else {
cronValue.year.type = '3';
cronValue.year.appoint = arr[6]?.split(',') || [];
}
}
function submit() {
const year = value_year.value ? ` ${value_year.value}` : '';
defaultValue.value = `${value_second.value} ${value_minute.value} ${
value_hour.value
} ${value_day.value} ${value_month.value} ${value_week.value}${year}`;
emit('update:modelValue', defaultValue.value);
dialogVisible.value = false;
}
function inputChange() {
emit('update:modelValue', defaultValue.value);
}
</script>
<template>
<Input
v-model="defaultValue"
class="input-with-select"
v-bind="$attrs"
@input="inputChange"
>
<template #addonAfter>
<Select v-model="select" placeholder="生成器" class="w-36">
<Select.Option value="0 * * * * ?">每分钟</Select.Option>
<Select.Option value="0 0 * * * ?">每小时</Select.Option>
<Select.Option value="0 0 0 * * ?">每天零点</Select.Option>
<Select.Option value="0 0 0 1 * ?">每月一号零点</Select.Option>
<Select.Option value="0 0 0 L * ?">每月最后一天零点</Select.Option>
<Select.Option value="0 0 0 ? * 1">每周星期日零点</Select.Option>
<Select.Option
v-for="(item, index) in shortcuts"
:key="index"
:value="item.value"
>
{{ item.text }}
</Select.Option>
<Select.Option value="custom">自定义</Select.Option>
</Select>
</template>
</Input>
<Dialog
v-model:open="dialogVisible"
:width="720"
destroy-on-close
title="cron规则生成器"
>
<div class="sc-cron">
<Tabs>
<Tabs.TabPane key="second">
<template #tab>
<div class="sc-cron-num">
<h2></h2>
<h4>{{ value_second }}</h4>
</div>
</template>
<Form>
<Form.Item label="类型">
<RadioGroup v-model="cronValue.second.type">
<RadioButton value="0">任意值</RadioButton>
<RadioButton value="1">范围</RadioButton>
<RadioButton value="2">间隔</RadioButton>
<RadioButton value="3">指定</RadioButton>
</RadioGroup>
</Form.Item>
<Form.Item v-if="cronValue.second.type === '1'" label="范围">
<InputNumber
v-model="cronValue.second.range.start"
:max="59"
:min="0"
controls-position="right"
/>
<span style="padding: 0 15px">-</span>
<InputNumber
v-model="cronValue.second.range.end"
:max="59"
:min="0"
controls-position="right"
/>
</Form.Item>
<Form.Item v-if="cronValue.second.type === '2'" label="间隔">
<InputNumber
v-model="cronValue.second.loop.start"
:max="59"
:min="0"
controls-position="right"
/>
秒开始
<InputNumber
v-model="cronValue.second.loop.end"
:max="59"
:min="0"
controls-position="right"
/>
秒执行一次
</Form.Item>
<Form.Item v-if="cronValue.second.type === '3'" label="指定">
<Select
v-model="cronValue.second.appoint"
mode="multiple"
style="width: 100%"
>
<Select.Option
v-for="(item, index) in data.second"
:key="index"
:label="item"
:value="item"
/>
</Select>
</Form.Item>
</Form>
</Tabs.TabPane>
<Tabs.TabPane key="minute">
<template #tab>
<div class="sc-cron-num">
<h2>分钟</h2>
<h4>{{ value_minute }}</h4>
</div>
</template>
<Form>
<Form.Item label="类型">
<RadioGroup v-model="cronValue.minute.type">
<RadioButton value="0">任意值</RadioButton>
<RadioButton value="1">范围</RadioButton>
<RadioButton value="2">间隔</RadioButton>
<RadioButton value="3">指定</RadioButton>
</RadioGroup>
</Form.Item>
<Form.Item v-if="cronValue.minute.type === '1'" label="范围">
<InputNumber
v-model="cronValue.minute.range.start"
:max="59"
:min="0"
controls-position="right"
/>
<span style="padding: 0 15px">-</span>
<InputNumber
v-model="cronValue.minute.range.end"
:max="59"
:min="0"
controls-position="right"
/>
</Form.Item>
<Form.Item v-if="cronValue.minute.type === '2'" label="间隔">
<InputNumber
v-model="cronValue.minute.loop.start"
:max="59"
:min="0"
controls-position="right"
/>
分钟开始
<InputNumber
v-model="cronValue.minute.loop.end"
:max="59"
:min="0"
controls-position="right"
/>
分钟执行一次
</Form.Item>
<Form.Item v-if="cronValue.minute.type === '3'" label="指定">
<Select
v-model="cronValue.minute.appoint"
mode="multiple"
style="width: 100%"
>
<Select.Option
v-for="(item, index) in data.minute"
:key="index"
:label="item"
:value="item"
/>
</Select>
</Form.Item>
</Form>
</Tabs.TabPane>
<Tabs.TabPane key="hour">
<template #tab>
<div class="sc-cron-num">
<h2>小时</h2>
<h4>{{ value_hour }}</h4>
</div>
</template>
<Form>
<Form.Item label="类型">
<RadioGroup v-model="cronValue.hour.type">
<RadioButton value="0">任意值</RadioButton>
<RadioButton value="1">范围</RadioButton>
<RadioButton value="2">间隔</RadioButton>
<RadioButton value="3">指定</RadioButton>
</RadioGroup>
</Form.Item>
<Form.Item v-if="cronValue.hour.type === '1'" label="范围">
<InputNumber
v-model="cronValue.hour.range.start"
:max="23"
:min="0"
controls-position="right"
/>
<span style="padding: 0 15px">-</span>
<InputNumber
v-model="cronValue.hour.range.end"
:max="23"
:min="0"
controls-position="right"
/>
</Form.Item>
<Form.Item v-if="cronValue.hour.type === '2'" label="间隔">
<InputNumber
v-model="cronValue.hour.loop.start"
:max="23"
:min="0"
controls-position="right"
/>
小时开始
<InputNumber
v-model="cronValue.hour.loop.end"
:max="23"
:min="0"
controls-position="right"
/>
小时执行一次
</Form.Item>
<Form.Item v-if="cronValue.hour.type === '3'" label="指定">
<Select
v-model="cronValue.hour.appoint"
mode="multiple"
style="width: 100%"
>
<Select.Option
v-for="(item, index) in data.hour"
:key="index"
:label="item"
:value="item"
/>
</Select>
</Form.Item>
</Form>
</Tabs.TabPane>
<Tabs.TabPane key="day">
<template #tab>
<div class="sc-cron-num">
<h2></h2>
<h4>{{ value_day }}</h4>
</div>
</template>
<Form>
<Form.Item label="类型">
<RadioGroup v-model="cronValue.day.type">
<RadioButton value="0">任意值</RadioButton>
<RadioButton value="1">范围</RadioButton>
<RadioButton value="2">间隔</RadioButton>
<RadioButton value="3">指定</RadioButton>
<RadioButton value="4">本月最后一天</RadioButton>
<RadioButton value="5">不指定</RadioButton>
</RadioGroup>
</Form.Item>
<Form.Item v-if="cronValue.day.type === '1'" label="范围">
<InputNumber
v-model="cronValue.day.range.start"
:max="31"
:min="1"
controls-position="right"
/>
<span style="padding: 0 15px">-</span>
<InputNumber
v-model="cronValue.day.range.end"
:max="31"
:min="1"
controls-position="right"
/>
</Form.Item>
<Form.Item v-if="cronValue.day.type === '2'" label="间隔">
<InputNumber
v-model="cronValue.day.loop.start"
:max="31"
:min="1"
controls-position="right"
/>
号开始
<InputNumber
v-model="cronValue.day.loop.end"
:max="31"
:min="1"
controls-position="right"
/>
天执行一次
</Form.Item>
<Form.Item v-if="cronValue.day.type === '3'" label="指定">
<Select
v-model="cronValue.day.appoint"
mode="multiple"
style="width: 100%"
>
<Select.Option
v-for="(item, index) in data.day"
:key="index"
:label="item"
:value="item"
/>
</Select>
</Form.Item>
</Form>
</Tabs.TabPane>
<Tabs.TabPane key="month">
<template #tab>
<div class="sc-cron-num">
<h2></h2>
<h4>{{ value_month }}</h4>
</div>
</template>
<Form>
<Form.Item label="类型">
<RadioGroup v-model="cronValue.month.type">
<RadioButton value="0">任意值</RadioButton>
<RadioButton value="1">范围</RadioButton>
<RadioButton value="2">间隔</RadioButton>
<RadioButton value="3">指定</RadioButton>
</RadioGroup>
</Form.Item>
<Form.Item v-if="cronValue.month.type === '1'" label="范围">
<InputNumber
v-model="cronValue.month.range.start"
:max="12"
:min="1"
controls-position="right"
/>
<span style="padding: 0 15px">-</span>
<InputNumber
v-model="cronValue.month.range.end"
:max="12"
:min="1"
controls-position="right"
/>
</Form.Item>
<Form.Item v-if="cronValue.month.type === '2'" label="间隔">
<InputNumber
v-model="cronValue.month.loop.start"
:max="12"
:min="1"
controls-position="right"
/>
月开始
<InputNumber
v-model="cronValue.month.loop.end"
:max="12"
:min="1"
controls-position="right"
/>
月执行一次
</Form.Item>
<Form.Item v-if="cronValue.month.type === '3'" label="指定">
<Select
v-model="cronValue.month.appoint"
mode="multiple"
style="width: 100%"
>
<Select.Option
v-for="(item, index) in data.month"
:key="index"
:label="item"
:value="item"
/>
</Select>
</Form.Item>
</Form>
</Tabs.TabPane>
<Tabs.TabPane key="week">
<template #tab>
<div class="sc-cron-num">
<h2></h2>
<h4>{{ value_week }}</h4>
</div>
</template>
<Form>
<Form.Item label="类型">
<RadioGroup v-model="cronValue.week.type">
<RadioButton value="0">任意值</RadioButton>
<RadioButton value="1">范围</RadioButton>
<RadioButton value="2">间隔</RadioButton>
<RadioButton value="3">指定</RadioButton>
<RadioButton value="4">本月最后一周</RadioButton>
<RadioButton value="5">不指定</RadioButton>
</RadioGroup>
</Form.Item>
<Form.Item v-if="cronValue.week.type === '1'" label="范围">
<Select v-model="cronValue.week.range.start">
<Select.Option
v-for="(item, index) in data.week"
:key="index"
:label="item.label"
:value="item.value"
/>
</Select>
<span style="padding: 0 15px">-</span>
<Select v-model="cronValue.week.range.end">
<Select.Option
v-for="(item, index) in data.week"
:key="index"
:label="item.label"
:value="item.value"
/>
</Select>
</Form.Item>
<Form.Item v-if="cronValue.week.type === '2'" label="间隔">
<InputNumber
v-model="cronValue.week.loop.start"
:max="4"
:min="1"
controls-position="right"
/>
周的星期
<Select v-model="cronValue.week.loop.end">
<Select.Option
v-for="(item, index) in data.week"
:key="index"
:label="item.label"
:value="item.value"
/>
</Select>
执行一次
</Form.Item>
<Form.Item v-if="cronValue.week.type === '3'" label="指定">
<Select
v-model="cronValue.week.appoint"
mode="multiple"
style="width: 100%"
>
<Select.Option
v-for="(item, index) in data.week"
:key="index"
:label="item.label"
:value="item.value"
/>
</Select>
</Form.Item>
<Form.Item v-if="cronValue.week.type === '4'" label="最后一周">
<Select v-model="cronValue.week.last">
<Select.Option
v-for="(item, index) in data.week"
:key="index"
:label="item.label"
:value="item.value"
/>
</Select>
</Form.Item>
</Form>
</Tabs.TabPane>
<Tabs.TabPane key="year">
<template #tab>
<div class="sc-cron-num">
<h2></h2>
<h4>{{ value_year }}</h4>
</div>
</template>
<Form>
<Form.Item label="类型">
<RadioGroup v-model="cronValue.year.type">
<RadioButton value="-1">忽略</RadioButton>
<RadioButton value="0">任意值</RadioButton>
<RadioButton value="1">范围</RadioButton>
<RadioButton value="2">间隔</RadioButton>
<RadioButton value="3">指定</RadioButton>
</RadioGroup>
</Form.Item>
<Form.Item v-if="cronValue.year.type === '1'" label="范围">
<InputNumber
v-model="cronValue.year.range.start"
controls-position="right"
/>
<span style="padding: 0 15px">-</span>
<InputNumber
v-model="cronValue.year.range.end"
controls-position="right"
/>
</Form.Item>
<Form.Item v-if="cronValue.year.type === '2'" label="间隔">
<InputNumber
v-model="cronValue.year.loop.start"
controls-position="right"
/>
年开始
<InputNumber
v-model="cronValue.year.loop.end"
:min="1"
controls-position="right"
/>
年执行一次
</Form.Item>
<Form.Item v-if="cronValue.year.type === '3'" label="指定">
<Select
v-model="cronValue.year.appoint"
mode="multiple"
style="width: 100%"
>
<Select.Option
v-for="(item, index) in data.year"
:key="index"
:label="item"
:value="item"
/>
</Select>
</Form.Item>
</Form>
</Tabs.TabPane>
</Tabs>
</div>
<template #footer>
<Button @click="dialogVisible = false"> </Button>
<Button theme="primary" @click="submit()"> </Button>
</template>
</Dialog>
</template>
<style scoped>
.sc-cron :deep(.ant-tabs-tab) {
height: auto;
padding: 0 7px;
line-height: 1;
vertical-align: bottom;
}
.sc-cron-num {
width: 100%;
margin-bottom: 15px;
text-align: center;
}
.sc-cron-num h2 {
margin-bottom: 15px;
font-size: 12px;
font-weight: normal;
}
.sc-cron-num h4 {
display: block;
width: 100%;
height: 32px;
padding: 0 15px;
font-size: 12px;
line-height: 30px;
background: hsl(var(--primary) / 10%);
border-radius: 4px;
}
.sc-cron :deep(.ant-tabs-tab.ant-tabs-tab-active) .sc-cron-num h4 {
color: #fff;
background: hsl(var(--primary));
}
[data-theme='dark'] .sc-cron-num h4 {
background: hsl(var(--white));
}
.input-with-select .ant-input-group-addon {
background-color: hsl(var(--muted));
}
</style>

View File

@@ -0,0 +1 @@
export { default as CronTab } from './cron-tab.vue';

View File

@@ -0,0 +1,266 @@
export interface ShortcutsType {
text: string;
value: string;
}
export interface CronRange {
start: number | string | undefined;
end: number | string | undefined;
}
export interface CronLoop {
start: number | string | undefined;
end: number | string | undefined;
}
export interface CronItem {
type: string;
range: CronRange;
loop: CronLoop;
appoint: string[];
last?: string;
}
export interface CronValue {
second: CronItem;
minute: CronItem;
hour: CronItem;
day: CronItem;
month: CronItem;
week: CronItem & { last: string };
year: CronItem;
}
export interface WeekOption {
value: string;
label: string;
}
export interface CronData {
second: string[];
minute: string[];
hour: string[];
day: string[];
month: string[];
week: WeekOption[];
year: number[];
}
const getYear = (): number[] => {
const v: number[] = [];
const y = new Date().getFullYear();
for (let i = 0; i < 11; i++) {
v.push(y + i);
}
return v;
};
export const CronValueDefault: CronValue = {
second: {
type: '0',
range: {
start: 1,
end: 2,
},
loop: {
start: 0,
end: 1,
},
appoint: [],
},
minute: {
type: '0',
range: {
start: 1,
end: 2,
},
loop: {
start: 0,
end: 1,
},
appoint: [],
},
hour: {
type: '0',
range: {
start: 1,
end: 2,
},
loop: {
start: 0,
end: 1,
},
appoint: [],
},
day: {
type: '0',
range: {
start: 1,
end: 2,
},
loop: {
start: 1,
end: 1,
},
appoint: [],
},
month: {
type: '0',
range: {
start: 1,
end: 2,
},
loop: {
start: 1,
end: 1,
},
appoint: [],
},
week: {
type: '5',
range: {
start: '2',
end: '3',
},
loop: {
start: 0,
end: '2',
},
last: '2',
appoint: [],
},
year: {
type: '-1',
range: {
start: getYear()[0],
end: getYear()[1],
},
loop: {
start: getYear()[0],
end: 1,
},
appoint: [],
},
};
export const CronDataDefault: CronData = {
second: [
'0',
'5',
'15',
'20',
'25',
'30',
'35',
'40',
'45',
'50',
'55',
'59',
],
minute: [
'0',
'5',
'15',
'20',
'25',
'30',
'35',
'40',
'45',
'50',
'55',
'59',
],
hour: [
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'10',
'11',
'12',
'13',
'14',
'15',
'16',
'17',
'18',
'19',
'20',
'21',
'22',
'23',
],
day: [
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'10',
'11',
'12',
'13',
'14',
'15',
'16',
'17',
'18',
'19',
'20',
'21',
'22',
'23',
'24',
'25',
'26',
'27',
'28',
'29',
'30',
'31',
],
month: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'],
week: [
{
value: '1',
label: '周日',
},
{
value: '2',
label: '周一',
},
{
value: '3',
label: '周二',
},
{
value: '4',
label: '周三',
},
{
value: '5',
label: '周四',
},
{
value: '6',
label: '周五',
},
{
value: '7',
label: '周六',
},
],
year: getYear(),
};

View File

@@ -0,0 +1,125 @@
<script lang="ts" setup>
import type { CSSProperties } from 'vue';
import type { CropperAvatarProps } from './typing';
import { computed, ref, unref, watch, watchEffect } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { Button } from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import cropperModal from './cropper-modal.vue';
defineOptions({ name: 'CropperAvatar' });
const props = withDefaults(defineProps<CropperAvatarProps>(), {
width: 200,
value: '',
showBtn: true,
btnProps: () => ({}),
btnText: '',
uploadApi: () => Promise.resolve(),
size: 5,
});
const emit = defineEmits(['update:value', 'change']);
const sourceValue = ref(props.value || '');
const [CropperModal, modalApi] = useVbenModal({
connectedComponent: cropperModal,
});
const getWidth = computed(() => `${`${props.width}`.replace(/px/, '')}px`);
const getIconWidth = computed(
() => `${Number.parseInt(`${props.width}`.replace(/px/, '')) / 2}px`,
);
const getStyle = computed((): CSSProperties => ({ width: unref(getWidth) }));
const getImageWrapperStyle = computed(
(): CSSProperties => ({ height: unref(getWidth), width: unref(getWidth) }),
);
watchEffect(() => {
sourceValue.value = props.value || '';
});
watch(
() => sourceValue.value,
(v: string) => {
emit('update:value', v);
},
);
function handleUploadSuccess({ data, source }: any) {
sourceValue.value = source;
emit('change', { data, source });
message.success($t('ui.cropper.uploadSuccess'));
}
const closeModal = () => modalApi.close();
const openModal = () => modalApi.open();
defineExpose({
closeModal,
openModal,
});
</script>
<template>
<!-- 头像容器 -->
<div class="inline-block text-center" :style="getStyle">
<!-- 图片包装器 -->
<div
class="bg-card group relative cursor-pointer overflow-hidden rounded-full border border-gray-200"
:style="getImageWrapperStyle"
@click="openModal"
>
<!-- 遮罩层 -->
<div
class="duration-400 absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black bg-opacity-40 opacity-0 transition-opacity group-hover:opacity-100"
:style="getImageWrapperStyle"
>
<IconifyIcon
icon="lucide:cloud-upload"
class="m-auto text-gray-400"
:style="{
...getImageWrapperStyle,
width: getIconWidth,
height: getIconWidth,
lineHeight: getIconWidth,
}"
/>
</div>
<!-- 头像图片 -->
<img
v-if="sourceValue"
:src="sourceValue"
alt="avatar"
class="h-full w-full object-cover"
/>
</div>
<!-- 上传按钮 -->
<Button
v-if="showBtn"
class="mx-auto mt-2"
@click="openModal"
v-bind="btnProps"
>
{{ btnText ? btnText : $t('ui.cropper.selectImage') }}
</Button>
<CropperModal
:size="size"
:src="sourceValue"
:upload-api="uploadApi"
@upload-success="handleUploadSuccess"
/>
</div>
</template>

View File

@@ -0,0 +1,304 @@
<script lang="ts" setup>
import type { UploadFile } from 'tdesign-vue-next';
import type { CropendResult, CropperModalProps, CropperType } from './typing';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { dataURLtoBlob, isFunction, isNumber } from '@vben/utils';
import { Avatar, Button, Space, Tooltip, Upload } from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import CropperImage from './cropper.vue';
defineOptions({ name: 'CropperModal' });
const props = withDefaults(defineProps<CropperModalProps>(), {
circled: true,
size: 0,
src: '',
uploadApi: () => Promise.resolve(),
});
const emit = defineEmits(['uploadSuccess', 'uploadError', 'register']);
let filename = '';
const src = ref(props.src || '');
const previewSource = ref('');
const cropper = ref<CropperType>();
let scaleX = 1;
let scaleY = 1;
const [Modal, modalApi] = useVbenModal({
onConfirm: handleOk,
onOpenChange(isOpen) {
if (isOpen) {
// 打开时,进行 loading 加载。后续 CropperImage 组件加载完毕,会自动关闭 loading通过 handleReady
modalLoading(true);
const img = new Image();
img.src = src.value;
img.addEventListener('load', () => {
modalLoading(false);
});
img.addEventListener('error', () => {
modalLoading(false);
});
} else {
// 关闭时,清空右侧预览
previewSource.value = '';
modalLoading(false);
}
},
});
function modalLoading(loading: boolean) {
modalApi.setState({ confirmLoading: loading, loading });
}
// Block upload
function handleBeforeUpload(file: UploadFile) {
if (
file &&
props.size > 0 &&
isNumber(file.size) &&
file.size > 1024 * 1024 * props.size
) {
emit('uploadError', { msg: $t('ui.cropper.imageTooBig') });
return false;
}
const reader = new FileReader();
reader.readAsDataURL(file.raw as Blob);
src.value = '';
previewSource.value = '';
reader.addEventListener('load', (e) => {
src.value = (e.target?.result as string) ?? '';
filename = file.name ?? '';
});
return false;
}
function handleCropend({ imgBase64 }: CropendResult) {
previewSource.value = imgBase64;
}
function handleReady(cropperInstance: CropperType) {
cropper.value = cropperInstance;
// 画布加载完毕 关闭 loading
modalLoading(false);
}
function handlerToolbar(event: string, arg?: number) {
if (event === 'scaleX') {
scaleX = arg = scaleX === -1 ? 1 : -1;
}
if (event === 'scaleY') {
scaleY = arg = scaleY === -1 ? 1 : -1;
}
(cropper?.value as any)?.[event]?.(arg);
}
async function handleOk() {
const uploadApi = props.uploadApi;
if (uploadApi && isFunction(uploadApi)) {
if (!previewSource.value) {
message.warning('未选择图片');
return;
}
const blob = dataURLtoBlob(previewSource.value);
try {
modalLoading(true);
const url = await uploadApi({ file: blob, filename, name: 'file' });
emit('uploadSuccess', { data: url, source: previewSource.value });
await modalApi.close();
} finally {
modalLoading(false);
}
}
}
</script>
<template>
<Modal
v-bind="$attrs"
:confirm-text="$t('ui.cropper.okText')"
:fullscreen-button="false"
:title="$t('ui.cropper.modalTitle')"
class="w-2/3"
>
<div class="flex h-96">
<!-- 左侧区域 -->
<div class="h-full w-3/5">
<!-- 裁剪器容器 -->
<div
class="relative h-[300px] bg-gradient-to-b from-neutral-50 to-neutral-200"
>
<CropperImage
v-if="src"
:circled="circled"
:src="src"
height="300px"
@cropend="handleCropend"
@ready="handleReady"
/>
</div>
<!-- 工具栏 -->
<div class="mt-4 flex items-center justify-between">
<Upload
:before-upload="handleBeforeUpload"
:file-list="[]"
accept="image/*"
>
<Tooltip :title="$t('ui.cropper.selectImage')" placement="bottom">
<Button size="small" theme="primary">
<template #icon>
<div class="flex items-center justify-center">
<IconifyIcon icon="lucide:upload" />
</div>
</template>
</Button>
</Tooltip>
</Upload>
<Space>
<Tooltip :title="$t('ui.cropper.btn_reset')" placement="bottom">
<Button
:disabled="!src"
size="small"
theme="primary"
@click="handlerToolbar('reset')"
>
<template #icon>
<div class="flex items-center justify-center">
<IconifyIcon icon="lucide:rotate-ccw" />
</div>
</template>
</Button>
</Tooltip>
<Tooltip
:title="$t('ui.cropper.btn_rotate_left')"
placement="bottom"
>
<Button
:disabled="!src"
size="small"
theme="primary"
@click="handlerToolbar('rotate', -45)"
>
<template #icon>
<div class="flex items-center justify-center">
<IconifyIcon icon="ant-design:rotate-left-outlined" />
</div>
</template>
</Button>
</Tooltip>
<Tooltip
:title="$t('ui.cropper.btn_rotate_right')"
placement="bottom"
>
<Button
:disabled="!src"
size="small"
theme="primary"
@click="handlerToolbar('rotate', 45)"
>
<template #icon>
<div class="flex items-center justify-center">
<IconifyIcon icon="ant-design:rotate-right-outlined" />
</div>
</template>
</Button>
</Tooltip>
<Tooltip :title="$t('ui.cropper.btn_scale_x')" placement="bottom">
<Button
:disabled="!src"
size="small"
theme="primary"
@click="handlerToolbar('scaleX')"
>
<template #icon>
<div class="flex items-center justify-center">
<IconifyIcon icon="vaadin:arrows-long-h" />
</div>
</template>
</Button>
</Tooltip>
<Tooltip :title="$t('ui.cropper.btn_scale_y')" placement="bottom">
<Button
:disabled="!src"
size="small"
theme="primary"
@click="handlerToolbar('scaleY')"
>
<template #icon>
<div class="flex items-center justify-center">
<IconifyIcon icon="vaadin:arrows-long-v" />
</div>
</template>
</Button>
</Tooltip>
<Tooltip :title="$t('ui.cropper.btn_zoom_in')" placement="bottom">
<Button
:disabled="!src"
size="small"
theme="primary"
@click="handlerToolbar('zoom', 0.1)"
>
<template #icon>
<div class="flex items-center justify-center">
<IconifyIcon icon="lucide:zoom-in" />
</div>
</template>
</Button>
</Tooltip>
<Tooltip :title="$t('ui.cropper.btn_zoom_out')" placement="bottom">
<Button
:disabled="!src"
size="small"
theme="primary"
@click="handlerToolbar('zoom', -0.1)"
>
<template #icon>
<div class="flex items-center justify-center">
<IconifyIcon icon="lucide:zoom-out" />
</div>
</template>
</Button>
</Tooltip>
</Space>
</div>
</div>
<!-- 右侧区域 -->
<div class="h-full w-2/5">
<!-- 预览区域 -->
<div
class="mx-auto h-56 w-56 overflow-hidden rounded-full border border-gray-200"
>
<img
v-if="previewSource"
:alt="$t('ui.cropper.preview')"
:src="previewSource"
class="h-full w-full object-cover"
/>
</div>
<!-- 头像组合预览 -->
<template v-if="previewSource">
<div
class="mt-2 flex items-center justify-around border-t border-gray-200 pt-2"
>
<Avatar :src="previewSource" size="large" />
<Avatar size="48" :src="previewSource" />
<Avatar size="64" :src="previewSource" />
<Avatar size="80" :src="previewSource" />
</div>
</template>
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,171 @@
<script lang="ts" setup>
import type { CSSProperties } from 'vue';
import type { CropperProps } from './typing';
import { computed, onMounted, onUnmounted, ref, unref, useAttrs } from 'vue';
import { useDebounceFn } from '@vueuse/core';
import Cropper from 'cropperjs';
import { defaultOptions } from './typing';
import 'cropperjs/dist/cropper.css';
defineOptions({ name: 'CropperImage' });
const props = withDefaults(defineProps<CropperProps>(), {
src: '',
alt: '',
circled: false,
realTimePreview: true,
height: '360px',
crossorigin: undefined,
imageStyle: () => ({}),
options: () => ({}),
});
const emit = defineEmits(['cropend', 'ready', 'cropendError']);
const attrs = useAttrs();
type ElRef<T extends HTMLElement = HTMLDivElement> = null | T;
const imgElRef = ref<ElRef<HTMLImageElement>>();
const cropper = ref<Cropper | null>();
const isReady = ref(false);
const debounceRealTimeCropped = useDebounceFn(realTimeCropped, 80);
const getImageStyle = computed((): CSSProperties => {
return {
height: props.height,
maxWidth: '100%',
...props.imageStyle,
};
});
const getClass = computed(() => {
return [
attrs.class,
{
'cropper-image--circled': props.circled,
},
];
});
const getWrapperStyle = computed((): CSSProperties => {
return { height: `${`${props.height}`.replace(/px/, '')}px` };
});
onMounted(init);
onUnmounted(() => {
cropper.value?.destroy();
});
async function init() {
const imgEl = unref(imgElRef);
if (!imgEl) {
return;
}
cropper.value = new Cropper(imgEl, {
...defaultOptions,
ready: () => {
isReady.value = true;
realTimeCropped();
emit('ready', cropper.value);
},
crop() {
debounceRealTimeCropped();
},
zoom() {
debounceRealTimeCropped();
},
cropmove() {
debounceRealTimeCropped();
},
...props.options,
});
}
// Real-time display preview
function realTimeCropped() {
props.realTimePreview && cropped();
}
// event: return base64 and width and height information after cropping
function cropped() {
if (!cropper.value) {
return;
}
const imgInfo = cropper.value.getData();
const canvas = props.circled
? getRoundedCanvas()
: cropper.value.getCroppedCanvas();
canvas.toBlob((blob) => {
if (!blob) {
return;
}
const fileReader: FileReader = new FileReader();
fileReader.readAsDataURL(blob);
fileReader.onloadend = (e) => {
emit('cropend', {
imgBase64: e.target?.result ?? '',
imgInfo,
});
};
fileReader.addEventListener('error', () => {
emit('cropendError');
});
}, 'image/png');
}
// Get a circular picture canvas
function getRoundedCanvas() {
const sourceCanvas = cropper.value!.getCroppedCanvas();
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d')!;
const width = sourceCanvas.width;
const height = sourceCanvas.height;
canvas.width = width;
canvas.height = height;
context.imageSmoothingEnabled = true;
context.drawImage(sourceCanvas, 0, 0, width, height);
context.globalCompositeOperation = 'destination-in';
context.beginPath();
context.arc(
width / 2,
height / 2,
Math.min(width, height) / 2,
0,
2 * Math.PI,
true,
);
context.fill();
return canvas;
}
</script>
<template>
<div :class="getClass" :style="getWrapperStyle">
<img
v-show="isReady"
ref="imgElRef"
:alt="alt"
:crossorigin="crossorigin"
:src="src"
:style="getImageStyle"
class="h-auto max-w-full"
/>
</div>
</template>
<style lang="scss">
.cropper-image {
&--circled {
.cropper-view-box,
.cropper-face {
border-radius: 50%;
}
}
}
</style>

View File

@@ -0,0 +1,3 @@
export { default as CropperAvatar } from './cropper-avatar.vue';
export { default as CropperImage } from './cropper.vue';
export type { CropperType } from './typing';

View File

@@ -0,0 +1,68 @@
import type Cropper from 'cropperjs';
import type { ButtonProps } from 'tdesign-vue-next';
import type { CSSProperties } from 'vue';
export interface apiFunParams {
file: Blob;
filename: string;
name: string;
}
export interface CropendResult {
imgBase64: string;
imgInfo: Cropper.Data;
}
export interface CropperProps {
src?: string;
alt?: string;
circled?: boolean;
realTimePreview?: boolean;
height?: number | string;
crossorigin?: '' | 'anonymous' | 'use-credentials' | undefined;
imageStyle?: CSSProperties;
options?: Cropper.Options;
}
export interface CropperAvatarProps {
width?: number | string;
value?: string;
showBtn?: boolean;
btnProps?: ButtonProps;
btnText?: string;
uploadApi?: (params: apiFunParams) => Promise<any>;
size?: number;
}
export interface CropperModalProps {
circled?: boolean;
uploadApi?: (params: apiFunParams) => Promise<any>;
src?: string;
size?: number;
}
export const defaultOptions: Cropper.Options = {
aspectRatio: 1,
zoomable: true,
zoomOnTouch: true,
zoomOnWheel: true,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: true,
autoCrop: true,
background: true,
highlight: true,
center: true,
responsive: true,
restore: true,
checkCrossOrigin: true,
checkOrientation: true,
scalable: true,
modal: true,
guides: true,
movable: true,
rotatable: true,
};
export type { Cropper as CropperType };

View File

@@ -0,0 +1,195 @@
<script lang="tsx">
import type { DescriptionsProps } from 'tdesign-vue-next';
import type { CSSProperties, PropType, Slots } from 'vue';
import type { DescriptionItemSchema, DescriptionProps } from './typing';
import { computed, defineComponent, ref, unref, useAttrs } from 'vue';
import { get, getNestedValue, isFunction } from '@vben/utils';
import { Card, Descriptions } from 'tdesign-vue-next';
const props = {
bordered: { default: true, type: Boolean },
column: {
default: () => {
return { lg: 3, md: 3, sm: 2, xl: 3, xs: 1, xxl: 4 };
},
type: [Number, Object],
},
data: { type: Object },
schema: {
default: () => [],
type: Array as PropType<DescriptionItemSchema[]>,
},
size: {
default: 'small',
type: String,
validator: (v: string) =>
['default', 'middle', 'small', undefined].includes(v),
},
title: { default: '', type: String },
useCard: { default: true, type: Boolean },
};
function getSlot(slots: Slots, slot: string, data?: any) {
if (!slots || !Reflect.has(slots, slot)) {
return null;
}
if (!isFunction(slots[slot])) {
console.error(`${slot} is not a function!`);
return null;
}
const slotFn = slots[slot];
if (!slotFn) return null;
return slotFn({ data });
}
export default defineComponent({
name: 'Description',
props,
setup(props, { slots }) {
const propsRef = ref<null | Partial<DescriptionProps>>(null);
const prefixCls = 'description';
const attrs = useAttrs();
// Custom title component: get title
const getMergeProps = computed(() => {
return {
...props,
...(unref(propsRef) as any),
} as DescriptionProps;
});
const getProps = computed(() => {
const opt = {
...unref(getMergeProps),
title: undefined,
};
return opt as DescriptionProps;
});
const useWrapper = computed(() => !!unref(getMergeProps).title);
const getDescriptionsProps = computed(() => {
return { ...unref(attrs), ...unref(getProps) } as DescriptionsProps;
});
// 防止换行
function renderLabel({
label,
labelMinWidth,
labelStyle,
}: DescriptionItemSchema) {
if (!labelStyle && !labelMinWidth) {
return label;
}
const labelStyles: CSSProperties = {
...labelStyle,
minWidth: `${labelMinWidth}px `,
};
return <div style={labelStyles}>{label}</div>;
}
function renderItem() {
const { data, schema } = unref(getProps);
return unref(schema)
.map((item) => {
const { contentMinWidth, field, render, show, span } = item;
if (show && isFunction(show) && !show(data)) {
return null;
}
function getContent() {
const _data = unref(getProps)?.data;
if (!_data) {
return null;
}
const getField = field.includes('.')
? (getNestedValue(_data, field) ?? get(_data, field))
: get(_data, field);
// if (
// getField &&
// !Object.prototype.hasOwnProperty.call(toRefs(_data), field)
// ) {
// return isFunction(render) ? render('', _data) : (getField ?? '');
// }
return isFunction(render)
? render(getField, _data)
: (getField ?? '');
}
const width = contentMinWidth;
return (
<Descriptions.Item
key={field}
label={renderLabel(item)}
span={span}
>
{() => {
if (item.slot) {
return getSlot(slots, item.slot, data);
}
if (!contentMinWidth) {
return getContent();
}
const style: CSSProperties = {
minWidth: `${width}px`,
};
return <div style={style}>{getContent()}</div>;
}}
</Descriptions.Item>
);
})
.filter((item) => !!item);
}
function renderDesc() {
return (
<Descriptions
class={`${prefixCls}`}
{...(unref(getDescriptionsProps) as any)}
>
{renderItem()}
</Descriptions>
);
}
function renderCard() {
const content = props.useCard ? renderDesc() : <div>{renderDesc()}</div>;
// Reduce the dom level
if (!props.useCard) {
return content;
}
const { title } = unref(getMergeProps);
const extraSlot = getSlot(slots, 'extra');
return (
<Card
bodyStyle={{ padding: '8px 0' }}
headerStyle={{
padding: '8px 16px',
fontSize: '14px',
minHeight: '24px',
}}
style={{ margin: 0 }}
title={title}
>
{{
default: () => content,
extra: () => extraSlot && <div>{extraSlot}</div>,
}}
</Card>
);
}
return () => (unref(useWrapper) ? renderCard() : renderDesc());
},
});
</script>

View File

@@ -0,0 +1,3 @@
export { default as Description } from './description.vue';
export * from './typing';
export { useDescription } from './use-description';

View File

@@ -0,0 +1,45 @@
import type { DescriptionsProps as TdDescriptionsProps } from 'tdesign-vue-next';
import type { JSX } from 'vue/jsx-runtime';
import type { CSSProperties, VNode } from 'vue';
import type { Recordable } from '@vben/types';
export interface DescriptionItemSchema {
labelMinWidth?: number;
contentMinWidth?: number;
// 自定义标签样式
labelStyle?: CSSProperties;
// 对应 data 中的字段名
field: string;
// 内容的描述
label: JSX.Element | string | VNode;
// 包含列的数量
span?: number;
// 是否显示
show?: (...arg: any) => boolean;
// 插槽名称
slot?: string;
// 自定义需要展示的内容
render?: (
val: any,
data?: Recordable<any>,
) => Element | JSX.Element | number | string | undefined | VNode;
}
export interface DescriptionProps extends TdDescriptionsProps {
// 是否包含卡片组件
useCard?: boolean;
// 描述项配置
schema: DescriptionItemSchema[];
// 数据
data: Recordable<any>;
// 标题
title?: string;
// 是否包含边框
bordered?: boolean;
}
export interface DescInstance {
setDescProps(descProps: Partial<DescriptionProps>): void;
}

View File

@@ -0,0 +1,31 @@
import type { Component } from 'vue';
import type { DescInstance, DescriptionProps } from './typing';
import { h, reactive } from 'vue';
import Description from './description.vue';
export function useDescription(options?: Partial<DescriptionProps>) {
const propsState = reactive<Partial<DescriptionProps>>(options || {});
const api: DescInstance = {
setDescProps: (descProps: Partial<DescriptionProps>): void => {
Object.assign(propsState, descProps);
},
};
// 创建一个包装组件,将 propsState 合并到 props 中
const DescriptionWrapper: Component = {
name: 'UseDescription',
inheritAttrs: false,
setup(_props, { attrs, slots }) {
return () => {
// @ts-ignore - 避免类型实例化过深
return h(Description, { ...propsState, ...attrs }, slots);
};
},
};
return [DescriptionWrapper, api] as const;
}

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed } from 'vue';
import { getDictObj } from '@vben/hooks';
import { isValidColor, TinyColor } from '@vben/utils';
import { Tag } from 'tdesign-vue-next';
interface DictTagProps {
/**
* 字典类型
*/
type: string;
/**
* 字典值
*/
value: any;
/**
* 图标
*/
icon?: string;
}
const props = defineProps<DictTagProps>();
/** 获取字典标签 */
const dictTag = computed(() => {
// 校验参数有效性
if (!props.type || props.value === undefined || props.value === null) {
return null;
}
// 获取字典对象
const dict = getDictObj(props.type, String(props.value));
if (!dict) {
return null;
}
// 处理颜色类型
let colorType = dict.colorType;
switch (colorType) {
case 'danger': {
colorType = 'danger';
break;
}
case 'info': {
colorType = 'success';
break;
}
case 'primary': {
colorType = 'primary';
break;
}
default: {
if (!colorType) {
colorType = 'default';
}
}
}
if (isValidColor(dict.cssClass)) {
colorType = new TinyColor(dict.cssClass).toHexString();
}
return {
label: dict.label || '',
theme: colorType as
| 'danger'
| 'default'
| 'primary'
| 'success'
| 'warning',
cssClass: dict.cssClass,
};
});
</script>
<template>
<Tag v-if="dictTag" :theme="dictTag.theme" :color="dictTag.cssClass">
{{ dictTag.label }}
</Tag>
</template>

View File

@@ -0,0 +1 @@
export { default as DictTag } from './dict-tag.vue';

View File

@@ -0,0 +1,14 @@
export const ACTION_ICON = {
DOWNLOAD: 'lucide:download',
UPLOAD: 'lucide:upload',
ADD: 'lucide:plus',
EDIT: 'lucide:edit',
DELETE: 'lucide:trash-2',
REFRESH: 'lucide:refresh-cw',
SEARCH: 'lucide:search',
FILTER: 'lucide:filter',
MORE: 'lucide:ellipsis-vertical',
VIEW: 'lucide:eye',
COPY: 'lucide:copy',
CLOSE: 'lucide:x',
};

View File

@@ -0,0 +1,4 @@
export * from './icons';
export { default as TableAction } from './table-action.vue';
export * from './typing';

View File

@@ -0,0 +1,282 @@
<!-- add by 星语参考 vben2 的方式增加 TableAction 组件 -->
<script setup lang="ts">
import type { PropType } from 'vue';
import type { ActionItem, PopConfirm } from './typing';
import { computed, unref, watch } from 'vue';
import { useAccess } from '@vben/access';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { isBoolean, isFunction } from '@vben/utils';
import {
Button,
Dropdown,
Menu,
Popconfirm,
Space,
Tooltip,
} from 'tdesign-vue-next';
const props = defineProps({
actions: {
type: Array as PropType<ActionItem[]>,
default() {
return [];
},
},
dropDownActions: {
type: Array as PropType<ActionItem[]>,
default() {
return [];
},
},
divider: {
type: Boolean,
default: true,
},
});
const { hasAccessByCodes } = useAccess();
/** 检查是否显示 */
function isIfShow(action: ActionItem): boolean {
const ifShow = action.ifShow;
let isIfShow = true;
if (isBoolean(ifShow)) {
isIfShow = ifShow;
}
if (isFunction(ifShow)) {
isIfShow = ifShow(action);
}
if (isIfShow) {
isIfShow =
hasAccessByCodes(action.auth || []) || (action.auth || []).length === 0;
}
return isIfShow;
}
/** 处理按钮 actions */
const getActions = computed(() => {
const actions = props.actions || [];
return actions.filter((action: ActionItem) => isIfShow(action));
});
/** 处理下拉菜单 actions */
const getDropdownList = computed(() => {
const dropDownActions = props.dropDownActions || [];
return dropDownActions.filter((action: ActionItem) => isIfShow(action));
});
/** Space 组件的 size */
const spaceSize = computed(() => {
const actions = unref(getActions);
return actions?.some(
(item: ActionItem) => item.type === 'primary' && item.variant === 'text',
)
? 4
: 8;
});
/** 获取 PopConfirm 属性 */
function getPopConfirmProps(popConfirm: PopConfirm) {
if (!popConfirm) return {};
const attrs: Record<string, any> = {};
// 复制基本属性,排除函数
Object.keys(popConfirm).forEach((key) => {
if (key !== 'confirm' && key !== 'cancel' && key !== 'icon') {
attrs[key] = popConfirm[key as keyof PopConfirm];
}
});
// 单独处理事件函数
if (popConfirm.confirm && isFunction(popConfirm.confirm)) {
attrs.onConfirm = popConfirm.confirm;
}
if (popConfirm.cancel && isFunction(popConfirm.cancel)) {
attrs.onCancel = popConfirm.cancel;
}
return attrs;
}
/** 获取 Button 属性 */
function getButtonProps(action: ActionItem) {
return {
theme: action.type || 'primary',
variant: action.variant || 'text',
disabled: action.disabled,
loading: action.loading,
size: action.size,
};
}
/** 获取 Tooltip 属性 */
function getTooltipProps(tooltip: any | string) {
if (!tooltip) return {};
return typeof tooltip === 'string' ? { title: tooltip } : { ...tooltip };
}
/** 处理菜单点击 */
function handleMenuClick(e: any) {
const action = getDropdownList.value[e.key];
if (action && action.onClick && isFunction(action.onClick)) {
action.onClick();
}
}
/** 生成稳定的 key */
function getActionKey(action: ActionItem, index: number) {
return `${action.label || ''}-${action.type || ''}-${index}`;
}
/** 处理按钮点击 */
function handleButtonClick(action: ActionItem) {
if (action.onClick && isFunction(action.onClick)) {
action.onClick();
}
}
/** 监听props变化强制重新计算 */
watch(
() => [props.actions, props.dropDownActions],
() => {
// 这里不需要额外处理computed会自动重新计算
},
{ deep: true },
);
</script>
<template>
<div class="table-actions">
<Space :size="spaceSize">
<template
v-for="(action, index) in getActions"
:key="getActionKey(action, index)"
>
<Popconfirm
v-if="action.popConfirm"
v-bind="getPopConfirmProps(action.popConfirm)"
>
<template v-if="action.popConfirm.icon" #icon>
<IconifyIcon :icon="action.popConfirm.icon" />
</template>
<Tooltip v-bind="getTooltipProps(action.tooltip)">
<Button v-bind="getButtonProps(action)">
<template v-if="action.icon" #icon>
<IconifyIcon :icon="action.icon" />
</template>
{{ action.label }}
</Button>
</Tooltip>
</Popconfirm>
<Tooltip v-else v-bind="getTooltipProps(action.tooltip)">
<Button
v-bind="getButtonProps(action)"
@click="handleButtonClick(action)"
>
<template v-if="action.icon" #icon>
<IconifyIcon :icon="action.icon" />
</template>
{{ action.label }}
</Button>
</Tooltip>
</template>
</Space>
<Dropdown v-if="getDropdownList.length > 0" trigger="hover">
<slot name="more">
<Button theme="primary" variant="text">
<template #icon>
{{ $t('page.action.more') }}
<IconifyIcon icon="lucide:ellipsis-vertical" />
</template>
</Button>
</slot>
<template #overlay>
<Menu>
<Menu.Item
v-for="(action, index) in getDropdownList"
:key="index"
:disabled="action.disabled"
@click="!action.popConfirm && handleMenuClick({ key: index })"
>
<template v-if="action.popConfirm">
<Popconfirm v-bind="getPopConfirmProps(action.popConfirm)">
<template v-if="action.popConfirm.icon" #icon>
<IconifyIcon :icon="action.popConfirm.icon" />
</template>
<div
:class="
action.disabled === true
? 'cursor-not-allowed text-gray-300'
: ''
"
>
<IconifyIcon v-if="action.icon" :icon="action.icon" />
<span :class="action.icon ? 'ml-1' : ''">
{{ action.label }}
</span>
</div>
</Popconfirm>
</template>
<template v-else>
<div
:class="
action.disabled === true
? 'cursor-not-allowed text-gray-300'
: ''
"
>
<IconifyIcon v-if="action.icon" :icon="action.icon" />
{{ action.label }}
</div>
</template>
</Menu.Item>
</Menu>
</template>
</Dropdown>
</div>
</template>
<style lang="scss">
.table-actions {
.ant-btn-link {
padding: 4px;
margin-left: 0;
}
.ant-btn > .iconify + span,
.ant-btn > span + .iconify {
margin-inline-start: 4px;
}
.iconify {
display: inline-flex;
align-items: center;
width: 1em;
height: 1em;
font-style: normal;
line-height: 0;
vertical-align: -0.125em;
color: inherit;
text-align: center;
text-transform: none;
text-rendering: optimizelegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
.ant-popconfirm {
.ant-popconfirm-buttons {
.ant-btn {
margin-inline-start: 4px !important;
}
}
}
</style>

View File

@@ -0,0 +1,32 @@
import type { TdButtonProps, TooltipProps } from 'tdesign-vue-next';
export interface PopConfirm {
title: string;
okText?: string;
cancelText?: string;
confirm: () => void;
cancel?: () => void;
icon?: string;
disabled?: boolean;
}
export interface ActionItem {
onClick?: () => void;
type?: TdButtonProps['theme'];
label?: string;
icon?: string;
color?: 'error' | 'success' | 'warning';
popConfirm?: PopConfirm;
disabled?: boolean;
divider?: boolean;
// 权限编码控制是否显示
auth?: string[];
// 业务控制是否显示
ifShow?: ((action: ActionItem) => boolean) | boolean;
tooltip?: string | TooltipProps;
loading?: boolean;
size?: TdButtonProps['size'];
shape?: TdButtonProps['shape'];
variant?: TdButtonProps['variant'];
danger?: boolean;
}

View File

@@ -0,0 +1,344 @@
<script lang="ts" setup>
import type { IPropTypes } from '@tinymce/tinymce-vue/lib/cjs/main/ts/components/EditorPropTypes';
import type { Editor as EditorType } from 'tinymce/tinymce';
import {
computed,
nextTick,
onActivated,
onBeforeUnmount,
onDeactivated,
onMounted,
ref,
unref,
useAttrs,
watch,
} from 'vue';
import { preferences, usePreferences } from '@vben/preferences';
import { buildShortUUID, isNumber } from '@vben/utils';
import Editor from '@tinymce/tinymce-vue';
import { useUpload } from '#/components/upload/use-upload';
import { bindHandlers } from './helper';
import ImgUpload from './img-upload.vue';
import {
plugins as defaultPlugins,
toolbar as defaultToolbar,
} from './tinymce';
type InitOptions = IPropTypes['init'];
defineOptions({ name: 'Tinymce', inheritAttrs: false });
const props = withDefaults(defineProps<TinymacProps>(), {
height: 400,
width: 'auto',
options: () => ({}),
plugins: defaultPlugins,
toolbar: defaultToolbar,
showImageUpload: true,
});
const emit = defineEmits(['change']);
interface TinymacProps {
options?: Partial<InitOptions>;
toolbar?: string;
plugins?: string;
height?: number | string;
width?: number | string;
showImageUpload?: boolean;
}
/** 外部使用 v-model 绑定值 */
const modelValue = defineModel('modelValue', { default: '', type: String });
/** TinyMCE 自托管https://www.jianshu.com/p/59a9c3802443 */
const tinymceScriptSrc = `${import.meta.env.VITE_BASE}tinymce/tinymce.min.js`;
const attrs = useAttrs();
const editorRef = ref<EditorType>();
const fullscreen = ref(false); // 图片上传,是否放到全屏的位置
const tinymceId = ref<string>(buildShortUUID('tiny-vue'));
const elRef = ref<HTMLElement | null>(null);
const containerWidth = computed(() => {
const width = props.width;
if (isNumber(width)) {
return `${width}px`;
}
return width;
});
/** 主题皮肤 */
const { isDark } = usePreferences();
const skinName = computed(() => {
return isDark.value ? 'oxide-dark' : 'oxide';
});
const contentCss = computed(() => {
return isDark.value ? 'dark' : 'default';
});
/** 国际化:需要在 langs 目录下,放好语言包 */
const { locale } = usePreferences();
const langName = computed(() => {
if (locale.value === 'en-US') {
return 'en';
}
return 'zh_CN';
});
/** 监听 mode、locale 进行主题、语言切换 */
const init = ref(true);
watch(
() => [preferences.theme.mode, preferences.app.locale],
async () => {
if (!editorRef.value) {
return;
}
// 通过 init + v-if 来挂载/卸载组件
destroy();
init.value = false;
await nextTick();
init.value = true;
// 等待加载完成
await nextTick();
setEditorMode();
},
);
const initOptions = computed((): InitOptions => {
const { height, options, plugins, toolbar } = props;
return {
height,
toolbar,
menubar: 'file edit view insert format tools table help',
plugins,
language: langName.value,
branding: false, // 禁止显示,右下角的“使用 TinyMCE 构建”
default_link_target: '_blank',
link_title: false,
object_resizing: true, // 和 vben2.0 不同,它默认是 false
auto_focus: undefined, // 和 vben2.0 不同,它默认是 true
skin: skinName.value,
content_css: contentCss.value,
content_style:
'body { font-family:Helvetica,Arial,sans-serif; font-size:16px }',
contextmenu: 'link image table',
image_advtab: true, // 图片高级选项
image_caption: true,
importcss_append: true,
noneditable_class: 'mceNonEditable',
paste_data_images: true, // 允许粘贴图片,默认 base64 格式images_upload_handler 启用时为上传
quickbars_selection_toolbar:
'bold italic | quicklink h2 h3 blockquote quickimage quicktable',
toolbar_mode: 'sliding',
...options,
images_upload_handler: (blobInfo: any) => {
return new Promise((resolve, reject) => {
const file = blobInfo.blob() as File;
const { httpRequest } = useUpload();
httpRequest(file)
.then((url) => {
resolve(url);
})
.catch((error) => {
console.error('tinymce 上传图片失败:', error);
reject(error.message);
});
});
},
setup: (editor: EditorType) => {
editorRef.value = editor;
editor.on('init', (e: any) => initSetup(e));
},
};
});
/** 监听 options.readonly 是否只读 */
const disabled = computed(() => props.options.readonly ?? false);
watch(
() => props.options,
(options) => {
const getDisabled = options && Reflect.get(options, 'readonly');
const editor = unref(editorRef);
if (editor) {
editor.mode.set(getDisabled ? 'readonly' : 'design');
}
},
);
onMounted(() => {
if (!initOptions.value.inline) {
tinymceId.value = buildShortUUID('tiny-vue');
}
nextTick(() => {
setTimeout(() => {
initEditor();
setEditorMode();
}, 30);
});
});
onBeforeUnmount(() => {
destroy();
});
onDeactivated(() => {
destroy();
});
onActivated(() => {
setEditorMode();
});
function setEditorMode() {
const editor = unref(editorRef);
if (editor) {
const mode = props.options.readonly ? 'readonly' : 'design';
editor.mode.set(mode);
}
}
function destroy() {
const editor = unref(editorRef);
editor?.destroy();
}
function initEditor() {
const el = unref(elRef);
if (el) {
el.style.visibility = '';
}
}
function initSetup(e: any) {
const editor = unref(editorRef);
if (!editor) {
return;
}
const value = modelValue.value || '';
editor.setContent(value);
bindModelHandlers(editor);
bindHandlers(e, attrs, unref(editorRef));
}
function setValue(editor: Record<string, any>, val?: string, prevVal?: string) {
if (
editor &&
typeof val === 'string' &&
val !== prevVal &&
val !== editor.getContent({ format: attrs.outputFormat })
) {
editor.setContent(val);
}
}
function bindModelHandlers(editor: any) {
const modelEvents = attrs.modelEvents ?? null;
const normalizedEvents = Array.isArray(modelEvents)
? modelEvents.join(' ')
: modelEvents;
watch(
() => modelValue.value,
(val, prevVal) => {
setValue(editor, val, prevVal);
},
);
editor.on(normalizedEvents || 'change keyup undo redo', () => {
const content = editor.getContent({ format: attrs.outputFormat });
emit('change', content);
});
editor.on('FullscreenStateChanged', (e: any) => {
fullscreen.value = e.state;
});
}
function getUploadingImgName(name: string) {
return `[uploading:${name}]`;
}
function handleImageUploading(name: string) {
const editor = unref(editorRef);
if (!editor) {
return;
}
editor.execCommand('mceInsertContent', false, getUploadingImgName(name));
const content = editor?.getContent() ?? '';
setValue(editor, content);
}
function handleDone(name: string, url: string) {
const editor = unref(editorRef);
if (!editor) {
return;
}
const content = editor?.getContent() ?? '';
const val =
content?.replace(getUploadingImgName(name), `<img src="${url}"/>`) ?? '';
setValue(editor, val);
}
function handleError(name: string) {
const editor = unref(editorRef);
if (!editor) {
return;
}
const content = editor?.getContent() ?? '';
const val = content?.replace(getUploadingImgName(name), '') ?? '';
setValue(editor, val);
}
</script>
<template>
<div :style="{ width: containerWidth }" class="app-tinymce">
<ImgUpload
v-if="showImageUpload"
v-show="editorRef"
:disabled="disabled"
:fullscreen="fullscreen"
@done="handleDone"
@error="handleError"
@uploading="handleImageUploading"
/>
<Editor
v-if="!initOptions.inline && init"
v-model="modelValue"
:init="initOptions"
:style="{ visibility: 'hidden', zIndex: 3000 }"
:tinymce-script-src="tinymceScriptSrc"
license-key="gpl"
/>
<slot v-else></slot>
</div>
</template>
<style lang="scss">
.tox.tox-silver-sink.tox-tinymce-aux {
z-index: 2025; /* 由于 vben modal/drawer 的 zIndex 为 2000需要调整 z-index默认 1300超过它避免遮挡 */
}
</style>
<style lang="scss" scoped>
.app-tinymce {
position: relative;
line-height: normal;
:deep(.textarea) {
z-index: -1;
visibility: hidden;
}
}
/* 隐藏右上角 tinymce upgrade 按钮 */
:deep(.tox-promotion) {
display: none !important;
}
</style>

View File

@@ -0,0 +1,85 @@
const validEvents = new Set([
'onActivate',
'onAddUndo',
'onBeforeAddUndo',
'onBeforeExecCommand',
'onBeforeGetContent',
'onBeforePaste',
'onBeforeRenderUI',
'onBeforeSetContent',
'onBlur',
'onChange',
'onClearUndos',
'onClick',
'onContextMenu',
'onCopy',
'onCut',
'onDblclick',
'onDeactivate',
'onDirty',
'onDrag',
'onDragDrop',
'onDragEnd',
'onDragGesture',
'onDragOver',
'onDrop',
'onExecCommand',
'onFocus',
'onFocusIn',
'onFocusOut',
'onGetContent',
'onHide',
'onInit',
'onKeyDown',
'onKeyPress',
'onKeyUp',
'onLoadContent',
'onMouseDown',
'onMouseEnter',
'onMouseLeave',
'onMouseMove',
'onMouseOut',
'onMouseOver',
'onMouseUp',
'onNodeChange',
'onObjectResized',
'onObjectResizeStart',
'onObjectSelected',
'onPaste',
'onPostProcess',
'onPostRender',
'onPreProcess',
'onProgressState',
'onRedo',
'onRemove',
'onReset',
'onSaveContent',
'onSelectionChange',
'onSetAttrib',
'onSetContent',
'onShow',
'onSubmit',
'onUndo',
'onVisualAid',
]);
const isValidKey = (key: string) => validEvents.has(key);
export const bindHandlers = (
initEvent: Event,
listeners: any,
editor: any,
): void => {
Object.keys(listeners)
.filter((element) => isValidKey(element))
.forEach((key: string) => {
const handler = listeners[key];
if (typeof handler === 'function') {
if (key === 'onInit') {
handler(initEvent, editor);
} else {
editor.on(key.slice(2), (e: any) => handler(e, editor));
}
}
});
};

View File

@@ -0,0 +1,111 @@
<script lang="ts" setup>
import type { RequestMethodResponse, UploadFile } from 'tdesign-vue-next';
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import { Button, Upload } from 'tdesign-vue-next';
import { useUpload } from '#/components/upload/use-upload';
defineOptions({ name: 'TinymceImageUpload' });
const props = defineProps({
disabled: {
default: false,
type: Boolean,
},
fullscreen: {
// 图片上传,是否放到全屏的位置
default: false,
type: Boolean,
},
});
const emit = defineEmits(['uploading', 'done', 'error']);
const uploading = ref(false);
const getButtonProps = computed(() => {
const { disabled } = props;
return {
disabled,
};
});
async function customRequest(
info: UploadFile | UploadFile[],
): Promise<RequestMethodResponse> {
// 处理单个文件上传
const uploadFile = Array.isArray(info) ? info[0] : info;
if (!uploadFile) {
return {
status: 'fail',
error: 'No file provided',
response: {},
};
}
// 1. emit 上传中
const file = uploadFile.raw as File;
const name = file?.name;
if (!uploading.value) {
emit('uploading', name);
uploading.value = true;
}
// 2. 执行上传
const { httpRequest } = useUpload();
try {
const url = await httpRequest(file);
emit('done', name, url);
uploadFile.onSuccess?.(url);
return {
status: 'success',
response: {
url,
},
};
} catch (error) {
emit('error', name);
uploadFile.onError?.(error);
return {
status: 'fail',
error: error instanceof Error ? error.message : 'Upload failed',
response: {},
};
} finally {
uploading.value = false;
}
}
</script>
<template>
<div :class="[{ fullscreen }]" class="tinymce-image-upload">
<Upload
:show-upload-list="false"
accept=".jpg,.jpeg,.gif,.png,.webp"
multiple
:request-method="customRequest"
>
<Button theme="primary" v-bind="{ ...getButtonProps }">
{{ $t('ui.upload.imgUpload') }}
</Button>
</Upload>
</div>
</template>
<style lang="scss" scoped>
.tinymce-image-upload {
position: absolute;
top: 4px;
right: 10px;
z-index: 20;
&.fullscreen {
position: fixed;
z-index: 10000;
}
}
</style>

View File

@@ -0,0 +1 @@
export { default as Tinymce } from './editor.vue';

View File

@@ -0,0 +1,17 @@
// Any plugins you want to setting has to be imported
// Detail plugins list see https://www.tiny.cloud/docs/plugins/
// Custom builds see https://www.tiny.cloud/download/custom-builds/
// colorpicker/contextmenu/textcolor plugin is now built in to the core editor, please remove it from your editor configuration
export const plugins =
'preview importcss searchreplace autolink autosave save directionality code visualblocks visualchars fullscreen image link media codesample table charmap pagebreak nonbreaking anchor insertdatetime advlist lists wordcount help emoticons accordion';
// 和 vben2.0 不同,从 https://www.tiny.cloud/ 拷贝 Vue 部分,然后去掉 importword exportword exportpdf | math 部分,并额外增加最后一行(来自 vben2.0 差异的部分)
export const toolbar =
'undo redo | accordion accordionremove | \\\n' +
' blocks fontfamily fontsize | bold italic underline strikethrough | \\\n' +
' align numlist bullist | link image | table media | \\\n' +
' lineheight outdent indent | forecolor backcolor removeformat | \\\n' +
' charmap emoticons | code fullscreen preview | save print | \\\n' +
' pagebreak anchor codesample | ltr rtl | \\\n' +
' hr searchreplace alignleft aligncenter alignright blockquote subscript superscript';

View File

@@ -0,0 +1,381 @@
<script lang="ts" setup>
import type {
RequestMethodResponse,
UploadFile,
UploadProps,
} from 'tdesign-vue-next';
import type { FileUploadProps } from './typing';
import type { AxiosProgressEvent } from '#/api/infra/file';
import { computed, ref, toRefs, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { isFunction, isNumber, isObject, isString } from '@vben/utils';
import { Button, Upload } from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import { checkFileType } from './helper';
import { UploadResultStatus } from './typing';
import { useUpload, useUploadType } from './use-upload';
defineOptions({ name: 'FileUpload', inheritAttrs: false });
const props = withDefaults(defineProps<FileUploadProps>(), {
value: () => [],
modelValue: undefined,
directory: undefined,
disabled: false,
drag: false,
helpText: '',
maxSize: 2,
maxNumber: 1,
accept: () => [],
multiple: false,
api: undefined,
resultField: '',
showDescription: false,
});
const emit = defineEmits([
'change',
'update:value',
'update:modelValue',
'delete',
'returnText',
'preview',
]);
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
const isInnerOperate = ref<boolean>(false);
const { getStringAccept } = useUploadType({
acceptRef: accept,
helpTextRef: helpText,
maxNumberRef: maxNumber,
maxSizeRef: maxSize,
});
// 计算当前绑定的值,优先使用 modelValue
const currentValue = computed(() => {
return props.modelValue === undefined ? props.value : props.modelValue;
});
// 判断是否使用 modelValue
const isUsingModelValue = computed(() => {
return props.modelValue !== undefined;
});
const fileList = ref<UploadProps['files']>([]);
const isLtMsg = ref<boolean>(true); // 文件大小错误提示
const isActMsg = ref<boolean>(true); // 文件类型错误提示
const isFirstRender = ref<boolean>(true); // 是否第一次渲染
const uploadNumber = ref<number>(0); // 上传文件计数器
const uploadList = ref<any[]>([]); // 临时上传列表
watch(
currentValue,
(v) => {
if (isInnerOperate.value) {
isInnerOperate.value = false;
return;
}
let value: string[] = [];
if (v) {
if (Array.isArray(v)) {
value = v;
} else {
value.push(v);
}
fileList.value = value.map((item, i) => {
if (item && isString(item)) {
return {
uid: `${-i}`,
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: UploadResultStatus.SUCCESS,
url: item,
};
} else if (item && isObject(item)) {
return item;
}
return null;
}) as UploadProps['files'];
}
if (!isFirstRender.value) {
emit('change', value);
isFirstRender.value = false;
}
},
{
immediate: true,
deep: true,
},
);
async function handleRemove(file: UploadFile) {
if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
index !== -1 && fileList.value.splice(index, 1);
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('update:modelValue', value);
emit('change', value);
emit('delete', file);
}
}
// 处理文件预览
function handlePreview(file: UploadFile) {
emit('preview', file);
}
// 处理文件数量超限
function handleExceed() {
message.error($t('ui.upload.maxNumber', [maxNumber.value]));
}
// 处理上传错误
function handleUploadError(error: any) {
console.error('上传错误:', error);
message.error('上传失败');
// 上传失败时减少计数器
uploadNumber.value = Math.max(0, uploadNumber.value - 1);
}
async function beforeUpload(file: UploadFile) {
const fileContent = await file.text();
emit('returnText', fileContent);
// 检查文件数量限制
if (
isNumber(fileList.value!.length) &&
fileList.value!.length >= props.maxNumber
) {
message.error($t('ui.upload.maxNumber', [props.maxNumber]));
return Upload.LIST_IGNORE;
}
const { maxSize, accept } = props;
const isAct = checkFileType(file.raw!, accept);
if (!isAct) {
message.error($t('ui.upload.acceptUpload', [accept]));
isActMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isActMsg.value = true), 1000);
return Upload.LIST_IGNORE;
}
const isLt = file.size! / 1024 / 1024 > maxSize;
if (isLt) {
message.error($t('ui.upload.maxSizeMultiple', [maxSize]));
isLtMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isLtMsg.value = true), 1000);
return Upload.LIST_IGNORE;
}
// 只有在验证通过后才增加计数器
uploadNumber.value++;
return true;
}
async function customRequest(
info: UploadFile | UploadFile[],
): Promise<RequestMethodResponse> {
// 处理单个文件上传
const uploadFile = Array.isArray(info) ? info[0] : info;
if (!uploadFile) {
return {
status: 'fail' as const,
error: 'No file provided',
response: {},
};
}
let { api } = props;
if (!api || !isFunction(api)) {
api = useUpload(props.directory).httpRequest;
}
try {
// 上传文件
const progressEvent: AxiosProgressEvent = (e) => {
const percent = Math.trunc((e.loaded / e.total!) * 100);
uploadFile.onProgress?.({ percent });
};
const res = await api?.(uploadFile.raw!, progressEvent);
// 处理上传成功后的逻辑
handleUploadSuccess(res!, uploadFile.raw as File);
uploadFile.onSuccess!(res);
message.success($t('ui.upload.uploadSuccess'));
// 提取 URL兼容不同的返回格式
const fileUrl = (res as any)?.url || (res as any)?.data || res;
return {
status: 'success' as const,
response: {
url: fileUrl,
},
};
} catch (error: any) {
console.error(error);
uploadFile.onError!(error);
handleUploadError(error);
return {
status: 'fail' as const,
error: error instanceof Error ? error.message : 'Upload failed',
response: {},
};
}
}
// 处理上传成功
function handleUploadSuccess(res: any, file: File) {
// 删除临时文件
const index = fileList.value?.findIndex((item) => item.name === file.name);
if (index !== -1) {
fileList.value?.splice(index!, 1);
}
// 添加到临时上传列表
const fileUrl = res?.url || res?.data || res;
uploadList.value.push({
name: file.name,
url: fileUrl,
status: UploadResultStatus.SUCCESS,
uid: file.name + Date.now(),
});
// 检查是否所有文件都上传完成
if (uploadList.value.length >= uploadNumber.value) {
fileList.value?.push(...uploadList.value);
uploadList.value = [];
uploadNumber.value = 0;
// 更新值
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('update:modelValue', value);
emit('change', value);
}
}
function getValue() {
const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.SUCCESS)
.map((item: any) => {
if (item?.response && props?.resultField) {
return item?.response;
}
return item?.url || item?.response?.url || item?.response;
});
// 单个文件的情况,根据输入参数类型决定返回格式
if (props.maxNumber === 1) {
const singleValue = list.length > 0 ? list[0] : '';
// 如果原始值是字符串或 modelValue 是字符串,返回字符串
if (
isString(props.value) ||
(isUsingModelValue.value && isString(props.modelValue))
) {
return singleValue;
}
return singleValue;
}
// 多文件情况,根据输入参数类型决定返回格式
if (isUsingModelValue.value) {
return Array.isArray(props.modelValue) ? list : list.join(',');
}
return Array.isArray(props.value) ? list : list.join(',');
}
</script>
<template>
<div>
<Upload
v-bind="$attrs"
v-model:file-list="fileList"
:accept="getStringAccept"
:before-upload="beforeUpload"
:request-method="customRequest"
:disabled="disabled"
:max-count="maxNumber"
:multiple="multiple"
list-type="text"
:progress="{ showInfo: true }"
:show-upload-list="{
showPreviewIcon: true,
showRemoveIcon: true,
showDownloadIcon: true,
}"
@remove="handleRemove"
@preview="handlePreview"
@reject="handleExceed"
>
<div v-if="drag" class="upload-drag-area">
<p class="upload-drag-icon">
<IconifyIcon icon="lucide:cloud-upload" />
</p>
<p class="upload-text">点击或拖拽文件到此区域上传</p>
<p class="upload-hint">
支持{{ accept.join('/') }}格式文件不超过{{ maxSize }}MB
</p>
</div>
<div v-else-if="fileList && fileList.length < maxNumber">
<Button>
<IconifyIcon icon="lucide:cloud-upload" />
{{ $t('ui.upload.upload') }}
</Button>
</div>
<div
v-if="showDescription && !drag"
class="mt-2 flex flex-wrap items-center"
>
请上传不超过
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
格式文件
</div>
</Upload>
</div>
</template>
<style scoped>
.upload-drag-area {
padding: 20px;
text-align: center;
background-color: var(--td-bg-color-container, #fafafa);
border: 2px dashed var(--td-border-level-2-color, #d9d9d9);
border-radius: var(--td-radius-default, 8px);
transition: border-color 0.3s;
}
.upload-drag-area:hover {
border-color: var(--td-brand-color, #0052d9);
}
.upload-drag-icon {
margin-bottom: 16px;
font-size: 48px;
color: var(--td-text-color-placeholder, #d9d9d9);
}
.upload-text {
margin-bottom: 8px;
font-size: 16px;
color: var(--td-text-color-secondary, #666);
}
.upload-hint {
font-size: 14px;
color: var(--td-text-color-placeholder, #999);
}
</style>

View File

@@ -0,0 +1,20 @@
/**
* 默认图片类型
*/
export const defaultImageAccepts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
export function checkFileType(file: File, accepts: string[]) {
if (!accepts || accepts.length === 0) {
return true;
}
const newTypes = accepts.join('|');
const reg = new RegExp(`${String.raw`\.(` + newTypes})$`, 'i');
return reg.test(file.name);
}
export function checkImgType(
file: File,
accepts: string[] = defaultImageAccepts,
) {
return checkFileType(file, accepts);
}

View File

@@ -0,0 +1,362 @@
<script lang="ts" setup>
import type { UploadFile, UploadProps } from 'tdesign-vue-next';
import type { FileUploadProps } from './typing';
import type { AxiosProgressEvent } from '#/api/infra/file';
import { computed, ref, toRefs, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { isFunction, isNumber, isObject, isString } from '@vben/utils';
import { Dialog, Upload } from 'tdesign-vue-next';
import { message } from '#/adapter/tdesign';
import { checkImgType, defaultImageAccepts } from './helper';
import { UploadResultStatus } from './typing';
import { useUpload, useUploadType } from './use-upload';
defineOptions({ name: 'ImageUpload', inheritAttrs: false });
const props = withDefaults(defineProps<FileUploadProps>(), {
value: () => [],
modelValue: undefined,
directory: undefined,
disabled: false,
listType: 'picture-card',
helpText: '',
maxSize: 2,
maxNumber: 1,
accept: () => defaultImageAccepts,
multiple: false,
api: undefined,
resultField: '',
showDescription: true,
});
const emit = defineEmits([
'change',
'update:value',
'update:modelValue',
'delete',
]);
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
const isInnerOperate = ref<boolean>(false);
const { getStringAccept } = useUploadType({
acceptRef: accept,
helpTextRef: helpText,
maxNumberRef: maxNumber,
maxSizeRef: maxSize,
});
// 计算当前绑定的值,优先使用 modelValue
const currentValue = computed(() => {
return props.modelValue === undefined ? props.value : props.modelValue;
});
// 判断是否使用 modelValue
const isUsingModelValue = computed(() => {
return props.modelValue !== undefined;
});
const previewOpen = ref<boolean>(false); // 是否展示预览
const previewImage = ref<string>(''); // 预览图片
const previewTitle = ref<string>(''); // 预览标题
const fileList = ref<UploadProps['files']>([]);
const isLtMsg = ref<boolean>(true); // 文件大小错误提示
const isActMsg = ref<boolean>(true); // 文件类型错误提示
const isFirstRender = ref<boolean>(true); // 是否第一次渲染
const uploadNumber = ref<number>(0); // 上传文件计数器
const uploadList = ref<any[]>([]); // 临时上传列表
watch(
currentValue,
async (v) => {
if (isInnerOperate.value) {
isInnerOperate.value = false;
return;
}
let value: string | string[] = [];
if (v) {
if (Array.isArray(v)) {
value = v;
} else {
value.push(v);
}
fileList.value = value.map((item, i) => {
if (item && isString(item)) {
return {
uid: `${-i}`,
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: UploadResultStatus.SUCCESS,
url: item,
};
} else if (item && isObject(item)) {
return item;
}
return null;
}) as UploadProps['files'];
}
if (!isFirstRender.value) {
emit('change', value);
isFirstRender.value = false;
}
},
{
immediate: true,
deep: true,
},
);
function getBase64<T extends ArrayBuffer | null | string>(file: File) {
return new Promise<T>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () => {
resolve(reader.result as T);
});
reader.addEventListener('error', (error) => reject(error));
});
}
async function handlePreview(file: UploadFile) {
if (!file.url && !file.preview) {
// TDesign 使用 raw 而不是 originFileObj
file.preview = await getBase64<string>(file.raw!);
}
previewImage.value = file.url || file.preview || '';
previewOpen.value = true;
previewTitle.value =
file.name ||
previewImage.value.slice(
Math.max(0, previewImage.value.lastIndexOf('/') + 1),
);
}
async function handleRemove(file: UploadFile) {
if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
index !== -1 && fileList.value.splice(index, 1);
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('update:modelValue', value);
emit('change', value);
emit('delete', file);
}
}
function handleCancel() {
previewOpen.value = false;
previewTitle.value = '';
}
async function beforeUpload(file: UploadFile) {
// 检查文件数量限制
if (
isNumber(fileList.value!.length) &&
fileList.value!.length >= props.maxNumber
) {
message.error($t('ui.upload.maxNumber', [props.maxNumber]));
return Upload.LIST_IGNORE;
}
const { maxSize, accept } = props;
const isAct = checkImgType(file.raw!, accept);
if (!isAct) {
message.error($t('ui.upload.acceptUpload', [accept]));
isActMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isActMsg.value = true), 1000);
return Upload.LIST_IGNORE;
}
const isLt = file.size! / 1024 / 1024 > maxSize;
if (isLt) {
message.error($t('ui.upload.maxSizeMultiple', [maxSize]));
isLtMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isLtMsg.value = true), 1000);
return Upload.LIST_IGNORE;
}
// 只有在验证通过后才增加计数器
uploadNumber.value++;
return true;
}
async function customRequest(info: UploadFile | UploadFile[]) {
// 处理单个文件上传
const uploadFile = Array.isArray(info) ? info[0] : info;
if (!uploadFile) {
return {
status: 'fail' as const,
error: 'No file provided',
response: {},
};
}
let { api } = props;
if (!api || !isFunction(api)) {
api = useUpload(props.directory).httpRequest;
}
try {
// 上传文件
const progressEvent: AxiosProgressEvent = (e) => {
const percent = Math.trunc((e.loaded / e.total!) * 100);
uploadFile.onProgress!({ percent });
};
// TDesign 使用 raw 而不是 file
const res = await api?.(uploadFile.raw as File, progressEvent);
// 处理上传成功后的逻辑
handleUploadSuccess(res, uploadFile.raw as File);
uploadFile.onSuccess!(res);
message.success($t('ui.upload.uploadSuccess'));
// 提取 URL兼容不同的返回格式
const fileUrl = (res as any)?.url || (res as any)?.data || res;
return {
status: 'success' as const,
response: {
url: fileUrl,
},
};
} catch (error: any) {
console.error(error);
uploadFile.onError!(error);
handleUploadError(error);
return {
status: 'fail' as const,
error: error instanceof Error ? error.message : 'Upload failed',
response: {},
};
}
}
// 处理上传成功
function handleUploadSuccess(res: any, file: File) {
// 删除临时文件
const index = fileList.value?.findIndex((item) => item.name === file.name);
if (index !== -1) {
fileList.value?.splice(index!, 1);
}
// 添加到临时上传列表
const fileUrl = res?.url || res?.data || res;
uploadList.value.push({
name: file.name,
url: fileUrl,
status: UploadResultStatus.SUCCESS,
uid: file.name + Date.now(),
});
// 检查是否所有文件都上传完成
if (uploadList.value.length >= uploadNumber.value) {
fileList.value?.push(...uploadList.value);
uploadList.value = [];
uploadNumber.value = 0;
// 更新值
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('update:modelValue', value);
emit('change', value);
}
}
// 处理上传错误
function handleUploadError(error: any) {
console.error('上传错误:', error);
// 上传失败时减少计数器
uploadNumber.value = Math.max(0, uploadNumber.value - 1);
}
function getValue() {
const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.SUCCESS)
.map((item: any) => {
if (item?.response && props?.resultField) {
return item?.response;
}
return item?.url || item?.response?.url || item?.response;
});
// 单个文件的情况,根据输入参数类型决定返回格式
if (props.maxNumber === 1) {
const singleValue = list.length > 0 ? list[0] : '';
// 如果原始值是字符串或 modelValue 是字符串,返回字符串
if (
isString(props.value) ||
(isUsingModelValue.value && isString(props.modelValue))
) {
return singleValue;
}
return singleValue;
}
// 多文件情况,根据输入参数类型决定返回格式
if (isUsingModelValue.value) {
return Array.isArray(props.modelValue) ? list : list.join(',');
}
return Array.isArray(props.value) ? list : list.join(',');
}
</script>
<template>
<div>
<Upload
v-bind="$attrs"
v-model:file-list="fileList"
:accept="getStringAccept"
:before-upload="beforeUpload"
:request-method="customRequest"
:disabled="disabled"
:list-type="listType"
:max-count="maxNumber"
:multiple="multiple"
:progress="{ showInfo: true }"
@preview="handlePreview"
@remove="handleRemove"
>
<div
v-if="fileList && fileList.length < maxNumber"
class="flex flex-col items-center justify-center"
>
<IconifyIcon icon="lucide:cloud-upload" />
<div class="mt-2">{{ $t('ui.upload.imgUpload') }}</div>
</div>
</Upload>
<div
v-if="showDescription"
class="mt-2 flex flex-wrap items-center text-sm"
>
请上传不超过
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
格式文件
</div>
<Dialog
:footer="false"
:visible="previewOpen"
:header="previewTitle"
@close="handleCancel"
>
<img :src="previewImage" alt="" class="w-full" />
</Dialog>
</div>
</template>
<style scoped>
/* TDesign 上传组件样式 */
:deep(.t-upload__card-item) {
@apply flex items-center justify-center;
}
</style>

View File

@@ -0,0 +1,3 @@
export { default as FileUpload } from './file-upload.vue';
export { default as ImageUpload } from './image-upload.vue';
export { default as InputUpload } from './input-upload.vue';

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import type { InputProps, TextareaProps } from 'tdesign-vue-next';
import type { FileUploadProps } from './typing';
import { computed } from 'vue';
import { useVModel } from '@vueuse/core';
import { Col, Input, Row, Textarea } from 'tdesign-vue-next';
import FileUpload from './file-upload.vue';
const props = defineProps<{
defaultValue?: number | string;
fileUploadProps?: FileUploadProps;
inputProps?: InputProps;
inputType?: 'input' | 'textarea';
modelValue?: number | string;
textareaProps?: TextareaProps;
}>();
const emits = defineEmits<{
(e: 'change', payload: number | string): void;
(e: 'update:value', payload: number | string): void;
(e: 'update:modelValue', payload: number | string): void;
}>();
const modelValue = useVModel(props, 'modelValue', emits, {
defaultValue: props.defaultValue,
passive: true,
});
function handleReturnText(text: string) {
modelValue.value = text;
emits('change', modelValue.value);
emits('update:value', modelValue.value);
emits('update:modelValue', modelValue.value);
}
const inputProps = computed(() => {
return {
...props.inputProps,
value: modelValue.value,
};
});
const textareaProps = computed(() => {
return {
...props.textareaProps,
value: modelValue.value,
};
});
const fileUploadProps = computed(() => {
return {
...props.fileUploadProps,
};
});
</script>
<template>
<Row>
<Col :span="18">
<Input readonly v-if="inputType === 'input'" v-bind="inputProps" />
<Textarea readonly v-else :row="4" v-bind="textareaProps" />
</Col>
<Col :span="6">
<FileUpload
class="ml-4"
v-bind="fileUploadProps"
@return-text="handleReturnText"
/>
</Col>
</Row>
</template>

View File

@@ -0,0 +1,39 @@
import type { AxiosResponse } from '@vben/request';
import type { AxiosProgressEvent } from '#/api/infra/file';
export enum UploadResultStatus {
ERROR = 'error',
PROGRESS = 'progress',
SUCCESS = 'success',
WAITING = 'waiting',
}
export type UploadListType = 'picture' | 'picture-card' | 'text';
export interface FileUploadProps {
// 根据后缀,或者其他
accept?: string[];
api?: (
file: File,
onUploadProgress?: AxiosProgressEvent,
) => Promise<AxiosResponse<any>>;
// 上传的目录
directory?: string;
disabled?: boolean;
drag?: boolean; // 是否支持拖拽上传
helpText?: string;
listType?: UploadListType;
// 最大数量的文件Infinity不限制
maxNumber?: number;
modelValue?: string | string[]; // v-model 支持
// 文件最大多少MB
maxSize?: number;
// 是否支持多选
multiple?: boolean;
// support xxx.xxx.xx
resultField?: string;
// 是否显示下面的描述
showDescription?: boolean;
value?: string | string[];
}

View File

@@ -0,0 +1,168 @@
import type { Ref } from 'vue';
import type { AxiosProgressEvent, InfraFileApi } from '#/api/infra/file';
import { computed, unref } from 'vue';
import { useAppConfig } from '@vben/hooks';
import { $t } from '@vben/locales';
import { createFile, getFilePresignedUrl, uploadFile } from '#/api/infra/file';
import { baseRequestClient } from '#/api/request';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
/**
* 上传类型
*/
enum UPLOAD_TYPE {
// 客户端直接上传只支持S3服务
CLIENT = 'client',
// 客户端发送到后端上传
SERVER = 'server',
}
export function useUploadType({
acceptRef,
helpTextRef,
maxNumberRef,
maxSizeRef,
}: {
acceptRef: Ref<string[]>;
helpTextRef: Ref<string>;
maxNumberRef: Ref<number>;
maxSizeRef: Ref<number>;
}) {
// 文件类型限制
const getAccept = computed(() => {
const accept = unref(acceptRef);
if (accept && accept.length > 0) {
return accept;
}
return [];
});
const getStringAccept = computed(() => {
return unref(getAccept)
.map((item) => {
return item.indexOf('/') > 0 || item.startsWith('.')
? item
: `.${item}`;
})
.join(',');
});
// 支持jpg、jpeg、png格式不超过2M最多可选择10张图片
const getHelpText = computed(() => {
const helpText = unref(helpTextRef);
if (helpText) {
return helpText;
}
const helpTexts: string[] = [];
const accept = unref(acceptRef);
if (accept.length > 0) {
helpTexts.push($t('ui.upload.accept', [accept.join(',')]));
}
const maxSize = unref(maxSizeRef);
if (maxSize) {
helpTexts.push($t('ui.upload.maxSize', [maxSize]));
}
const maxNumber = unref(maxNumberRef);
if (maxNumber && maxNumber !== Infinity) {
helpTexts.push($t('ui.upload.maxNumber', [maxNumber]));
}
return helpTexts.join('');
});
return { getAccept, getStringAccept, getHelpText };
}
// TODO @芋艿:目前保持和 admin-vue3 一致,后续可能重构
export function useUpload(directory?: string) {
// 后端上传地址
const uploadUrl = getUploadUrl();
// 是否使用前端直连上传
const isClientUpload =
UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE;
// 重写ElUpload上传方法
async function httpRequest(
file: File,
onUploadProgress?: AxiosProgressEvent,
) {
// 模式一:前端上传
if (isClientUpload) {
// 1.1 生成文件名称
const fileName = await generateFileName(file);
// 1.2 获取文件预签名地址
const presignedInfo = await getFilePresignedUrl(fileName, directory);
// 1.3 上传文件
return baseRequestClient
.put(presignedInfo.uploadUrl, file, {
headers: {
'Content-Type': file.type,
},
})
.then(() => {
// 1.4. 记录文件信息到后端(异步)
createFile0(presignedInfo, file);
// 通知成功,数据格式保持与后端上传的返回结果一致
return { url: presignedInfo.url };
});
} else {
// 模式二:后端上传
return uploadFile({ file, directory }, onUploadProgress);
}
}
return {
uploadUrl,
httpRequest,
};
}
/**
* 获得上传 URL
*/
export function getUploadUrl(): string {
return `${apiURL}/infra/file/upload`;
}
/**
* 创建文件信息
*
* @param vo 文件预签名信息
* @param file 文件
*/
function createFile0(
vo: InfraFileApi.FilePresignedUrlRespVO,
file: File,
): InfraFileApi.File {
const fileVO = {
configId: vo.configId,
url: vo.url,
path: vo.path,
name: file.name,
type: file.type,
size: file.size,
};
createFile(fileVO);
return fileVO;
}
/**
* 生成文件名称使用算法SHA256
*
* @param file 要上传的文件
*/
async function generateFileName(file: File) {
// // 读取文件内容
// const data = await file.arrayBuffer();
// const wordArray = CryptoJS.lib.WordArray.create(data);
// // 计算SHA256
// const sha256 = CryptoJS.SHA256(wordArray).toString();
// // 拼接后缀
// const ext = file.name.slice(Math.max(0, file.name.lastIndexOf('.')));
// return `${sha256}${ext}`;
return file.name;
}

View File

@@ -0,0 +1,14 @@
{
"rangePicker": {
"today": "Today",
"last7Days": "Last 7 Days",
"last30Days": "Last 30 Days",
"yesterday": "Yesterday",
"thisWeek": "This Week",
"thisMonth": "This Month",
"lastWeek": "Last Week",
"lastMonth": "Last Month",
"beginTime": "Begin Time",
"endTime": "End Time"
}
}

View File

@@ -0,0 +1,14 @@
{
"rangePicker": {
"today": "今天",
"last7Days": "最近 7 天",
"last30Days": "最近 30 天",
"yesterday": "昨天",
"thisWeek": "本周",
"thisMonth": "本月",
"lastWeek": "上周",
"lastMonth": "上月",
"beginTime": "开始时间",
"endTime": "结束时间"
}
}

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="bg-card flex h-[95%] flex-col rounded-md 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,119 @@
<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: [], // 已选择的表列表
});
/** 表格实例 */
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useImportTableFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useImportTableColumns(),
height: 600,
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
if (formValues.dataSourceConfigId === undefined) {
return [];
}
formData.dataSourceConfigId = formValues.dataSourceConfigId;
return await getSchemaTableList({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'name',
isHover: true,
},
toolbarConfig: {
enabled: false,
},
checkboxConfig: {
highlight: true,
range: true,
},
pagerConfig: {
enabled: false,
},
} as VxeTableGridOptions<InfraCodegenApi.DatabaseTable>,
gridEvents: {
checkboxChange: ({
records,
}: {
records: InfraCodegenApi.DatabaseTable[];
}) => {
formData.tableNames = records.map((item) => item.name);
},
},
});
/** 模态框实例 */
const [Modal, modalApi] = useVbenModal({
title: '导入表',
class: 'w-1/2',
async onConfirm() {
modalApi.lock();
// 1.1 获取表单值
if (formData?.dataSourceConfigId === undefined) {
message.error('请选择数据源');
return;
}
// 1.2 校验是否选择了表
if (formData.tableNames.length === 0) {
message.error('请选择需要导入的表');
return;
}
// 2. 提交请求
const hideLoading = message.loading({
content: '导入中...',
duration: 0,
});
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,159 @@
<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[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
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'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
variant: 'text',
type: 'danger',
icon: ACTION_ICON.DELETE,
auth: ['infra:data-source-config:delete'],
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' },
},
];
}

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