feat: naive system init

This commit is contained in:
xingyu4j
2025-10-16 16:35:02 +08:00
parent bac9b0d747
commit 757eb72018
97 changed files with 13178 additions and 7 deletions

View File

@@ -145,14 +145,12 @@ setupVbenVxeTable({
const loadingKey = `__loading_${column.field}`;
const finallyProps = {
inlinePrompt: true,
activeText: $t('common.enabled'),
inactiveText: $t('common.disabled'),
activeValue: 1,
inactiveValue: 0,
checkedValue: 0,
uncheckedValue: 1,
...props,
modelValue: row[column.field],
value: row[column.field],
loading: row[loadingKey] ?? false,
'onUpdate:modelValue': onChange,
'onUpdate:value': onChange,
};
async function onChange(newVal: any) {
@@ -167,7 +165,10 @@ setupVbenVxeTable({
}
}
return h(NSwitch, finallyProps);
return h(NSwitch, finallyProps, {
checked: $t('common.enabled'),
unchecked: $t('common.disabled'),
});
},
});
@@ -206,4 +207,5 @@ setupVbenVxeTable({
export { useVbenVxeGrid };
export * from '#/components/table-action';
export type * from '@vben/plugins/vxe-table';

View File

@@ -0,0 +1,79 @@
<script lang="tsx">
import type { DescriptionsProps } from 'naive-ui';
import type { PropType } from 'vue';
import type { DescriptionItemSchema, DescriptionsOptions } from './typing';
import { defineComponent } from 'vue';
import { get } from '@vben/utils';
import { NDescriptions, NDescriptionsItem } from 'naive-ui';
/** 对 Descriptions 进行二次封装 */
const Description = defineComponent({
name: 'Descriptions',
props: {
data: {
type: Object as PropType<Record<string, any>>,
default: () => ({}),
},
schema: {
type: Array as PropType<DescriptionItemSchema[]>,
default: () => [],
},
// Descriptions 原生 props
componentProps: {
type: Object as PropType<DescriptionsProps>,
default: () => ({}),
},
},
setup(props: DescriptionsOptions) {
// TODO @xingyu每个 field 的 slot 的考虑
// TODO @xingyufrom 5.0extra: () => getSlot(slots, 'extra')
/** 过滤掉不需要展示的 */
const shouldShowItem = (item: DescriptionItemSchema) => {
if (item.hidden === undefined) return true;
return typeof item.hidden === 'function'
? !item.hidden(props.data)
: !item.hidden;
};
/** 渲染内容 */
const renderContent = (item: DescriptionItemSchema) => {
if (item.content) {
return typeof item.content === 'function'
? item.content(props.data)
: item.content;
}
return item.field ? get(props.data, item.field) : null;
};
return () => (
<NDescriptions
{...props}
bordered={props.componentProps?.bordered}
column={props.componentProps?.column}
size={props.componentProps?.size}
title={props.componentProps?.title}
>
{props.schema?.filter(shouldShowItem).map((item) => (
<NDescriptionsItem
contentStyle={item.contentStyle}
key={item.field || String(item.label)}
label={item.label as string}
labelStyle={item.labelStyle}
span={item.span}
>
{renderContent(item)}
</NDescriptionsItem>
))}
</NDescriptions>
);
},
});
// TODO @xingyufrom 5.0emits: ['register'] 事件
export default Description;
</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,27 @@
import type { DescriptionsProps } from 'naive-ui';
import type { CSSProperties, VNode } from 'vue';
// TODO @xingyu【content】这个纠结下1vben2.0 是 renderhttps://doc.vvbin.cn/components/desc.html#usage 2
// TODO @xingyuvben2.0 还有 sapn【done】、labelMinWidth、contentMinWidth
// TODO @xingyu【hidden】这个纠结下1vben2.0 是 show
export interface DescriptionItemSchema {
label: string | VNode; // 内容的描述
field?: string; // 对应 data 中的字段名
content?: ((data: any) => string | VNode) | string | VNode; // 自定义需要展示的内容,比如说 dict-tag
span?: number; // 包含列的数量
labelStyle?: CSSProperties; // 自定义标签样式
contentStyle?: CSSProperties; // 自定义内容样式
hidden?: ((data: any) => boolean) | boolean; // 是否显示
}
// TODO @xingyuvben2.0 还有 title【done】、bordered【done】d、useCollapse、collapseOptions
// TODO @xingyufrom 5.0bordered 默认为 true
// TODO @xingyufrom 5.0column 默认为 lg: 3, md: 3, sm: 2, xl: 3, xs: 1, xxl: 4
// TODO @xingyufrom 5.0size 默认为 small有 'default', 'middle', 'small', undefined
// TODO @xingyufrom 5.0useCollapse 默认为 true
export interface DescriptionsOptions {
data?: Record<string, any>; // 数据
schema?: DescriptionItemSchema[]; // 描述项配置
componentProps?: DescriptionsProps; // antd Descriptions 组件参数
}

View File

@@ -0,0 +1,71 @@
import type { DescriptionsOptions } from './typing';
import { defineComponent, h, isReactive, reactive, watch } from 'vue';
import Description from './description.vue';
/** 描述列表 api 定义 */
class DescriptionApi {
private state = reactive<Record<string, any>>({});
constructor(options: DescriptionsOptions) {
this.state = { ...options };
}
getState(): DescriptionsOptions {
return this.state as DescriptionsOptions;
}
// TODO @xingyu【setState】纠结下1vben2.0 是 data https://doc.vvbin.cn/components/desc.html#usage
setState(newState: Partial<DescriptionsOptions>) {
this.state = { ...this.state, ...newState };
}
}
export type ExtendedDescriptionApi = DescriptionApi;
export function useDescription(options: DescriptionsOptions) {
const IS_REACTIVE = isReactive(options);
const api = new DescriptionApi(options);
// 扩展 API
const extendedApi: ExtendedDescriptionApi = api as never;
const Desc = defineComponent({
name: 'UseDescription',
inheritAttrs: false,
setup(_, { attrs, slots }) {
// 合并props和attrs到state
api.setState({ ...attrs });
return () =>
h(
Description,
{
...api.getState(),
...attrs,
},
slots,
);
},
});
// 响应式支持
if (IS_REACTIVE) {
watch(
() => options.schema,
(newSchema) => {
api.setState({ schema: newSchema });
},
{ immediate: true, deep: true },
);
watch(
() => options.data,
(newData) => {
api.setState({ data: newData });
},
{ immediate: true, deep: true },
);
}
return [Desc, extendedApi] as const;
}

View File

@@ -0,0 +1,13 @@
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',
};

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,221 @@
<!-- add by 星语参考 vben2 的方式增加 TableAction 组件 -->
<script setup lang="ts">
import type { DropdownMixedOption } from 'naive-ui/es/dropdown/src/interface';
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 { isBoolean, isFunction } from '@vben/utils';
import { NButton, NDropdown, NPopconfirm, NSpace } from 'naive-ui';
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((): DropdownMixedOption[] => {
const dropDownActions = props.dropDownActions || [];
return dropDownActions
.filter((action: ActionItem) => isIfShow(action))
.map((action: ActionItem, index: number) => ({
label: action.label || '',
onClick: () => action.onClick?.(),
key: getActionKey(action, index),
disabled: action.disabled,
divider: action.divider || false,
}));
});
/** Space 组件的 size */
const spaceSize = computed(() => {
const actions = unref(getActions);
return actions?.some((item: ActionItem) => item.type === 'primary') ? 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.positiveConfirm = popConfirm.confirm;
}
if (popConfirm.cancel && isFunction(popConfirm.cancel)) {
attrs.negativeCancel = popConfirm.cancel;
}
if (popConfirm.okText) {
attrs.positiveText = popConfirm.okText;
}
if (popConfirm.cancelText) {
attrs.negativeText = popConfirm.cancelText;
}
return attrs;
}
/** 获取 Button 属性 */
function getButtonProps(action: ActionItem) {
return {
type: action.type || 'primary',
quaternary: action.quaternary || false,
text: action.text || false,
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(key: number) {
const action = getDropdownList.value.find((item) => item.key === 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();
}
}
function handlePopconfirmClick(popconfirm: PopConfirm, type: string) {
if (type === 'positive') {
popconfirm.confirm();
} else if (type === 'negative' && popconfirm.cancel) {
popconfirm.cancel();
}
}
/** 监听props变化强制重新计算 */
watch(
() => [props.actions, props.dropDownActions],
() => {
// 这里不需要额外处理computed会自动重新计算
},
{ deep: true },
);
</script>
<template>
<div class="flex">
<NSpace :size="spaceSize">
<template
v-for="(action, index) in getActions"
:key="getActionKey(action, index)"
>
<NPopconfirm
v-if="action.popConfirm"
v-bind="getPopConfirmProps(action.popConfirm)"
@positive-click="handlePopconfirmClick(action.popConfirm, 'positive')"
@negative-click="handlePopconfirmClick(action.popConfirm, 'negative')"
>
<template v-if="action.popConfirm.icon" #icon>
<IconifyIcon :icon="action.popConfirm.icon" />
</template>
<template #trigger>
<NButton v-bind="getButtonProps(action)">
<template v-if="action.icon" #icon>
<IconifyIcon :icon="action.icon" />
</template>
{{ action.label }}
</NButton>
</template>
<span>{{ getPopConfirmProps(action.popConfirm).title }}</span>
</NPopconfirm>
<NButton
v-else
v-bind="getButtonProps(action)"
@click="handleButtonClick(action)"
>
<template v-if="action.icon" #icon>
<IconifyIcon :icon="action.icon" />
</template>
{{ action.label }}
</NButton>
</template>
</NSpace>
<NDropdown
v-if="getDropdownList.length > 0"
trigger="click"
:options="getDropdownList"
:show-arrow="true"
@select="handleMenuClick"
>
<NButton type="primary" text>
{{ $t('page.action.more') }}
<template #icon>
<IconifyIcon icon="lucide:ellipsis-vertical" />
</template>
</NButton>
</NDropdown>
</div>
</template>

View File

@@ -0,0 +1,28 @@
import type { ButtonProps } from 'naive-ui/es/button/src/Button';
import type { TooltipProps } from 'naive-ui/es/tooltip/src/Tooltip';
export interface PopConfirm {
title: string;
okText?: string;
cancelText?: string;
confirm: () => void;
cancel?: () => void;
icon?: string;
disabled?: boolean;
}
export interface ActionItem extends ButtonProps {
onClick?: () => void;
type?: ButtonProps['type'];
label?: string;
color?: 'error' | 'success' | 'warning';
icon?: string;
popConfirm?: PopConfirm;
disabled?: boolean;
divider?: boolean;
// 权限编码控制是否显示
auth?: string[];
// 业务控制是否显示
ifShow?: ((action: ActionItem) => boolean) | boolean;
tooltip?: string | TooltipProps;
}

View File

@@ -0,0 +1 @@
export * from './rangePickerProps';

View File

@@ -0,0 +1,53 @@
import dayjs from 'dayjs';
import { $t } from '#/locales';
/** 时间段选择器拓展 */
export function getRangePickerDefaultProps() {
return {
startPlaceholder: $t('utils.rangePicker.beginTime'),
endPlaceholder: $t('utils.rangePicker.endTime'),
type: 'datetimerange',
// TODO
format: 'YYYY-MM-dd HH:mm:ss',
valueFormat: 'YYYY-MM-dd HH:mm:ss',
defaultTime: ['00:00:00', '23:59:59'],
shortcuts: {
[$t('utils.rangePicker.today')]: () =>
[
dayjs().startOf('day').toDate(),
dayjs().endOf('day').toDate(),
] as const,
[$t('utils.rangePicker.last7Days')]: () =>
[
dayjs().subtract(7, 'day').startOf('day').toDate(),
dayjs().endOf('day').toDate(),
] as const,
[$t('utils.rangePicker.last30Days')]: () =>
[
dayjs().subtract(30, 'day').startOf('day').toDate(),
dayjs().endOf('day').toDate(),
] as const,
[$t('utils.rangePicker.yesterday')]: () =>
[
dayjs().subtract(1, 'day').startOf('day').toDate(),
dayjs().subtract(1, 'day').endOf('day').toDate(),
] as const,
[$t('utils.rangePicker.thisWeek')]: () =>
[
dayjs().startOf('week').toDate(),
dayjs().endOf('day').toDate(),
] as const,
[$t('utils.rangePicker.thisMonth')]: () =>
[
dayjs().startOf('month').toDate(),
dayjs().endOf('day').toDate(),
] as const,
[$t('utils.rangePicker.lastWeek')]: () =>
[
dayjs().subtract(1, 'week').startOf('day').toDate(),
dayjs().endOf('day').toDate(),
] as const,
},
};
}

View File

@@ -0,0 +1,48 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemAreaApi } from '#/api/system/area';
import { z } from '#/adapter/form';
/** 查询 IP 的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'ip',
label: 'IP 地址',
component: 'Input',
componentProps: {
placeholder: '请输入 IP 地址',
},
rules: z.string().ip({ message: '请输入正确的 IP 地址' }),
},
{
fieldName: 'result',
label: '地址',
component: 'Input',
componentProps: {
placeholder: '展示查询 IP 结果',
readonly: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<SystemAreaApi.Area>['columns'] {
return [
{
field: 'id',
title: '地区编码',
minWidth: 120,
align: 'left',
fixed: 'left',
treeNode: true,
},
{
field: 'name',
title: '地区名称',
minWidth: 200,
},
];
}

View File

@@ -0,0 +1,79 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAreaTree } from '#/api/system/area';
import { useGridColumns } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 查询 IP */
function handleQueryIp() {
formModalApi.setData(null).open();
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async () => {
return await getAreaTree();
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
},
treeConfig: {
rowField: 'id',
reserve: true,
},
} as VxeTableGridOptions,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="地区 & IP" url="https://doc.iocoder.cn/area-and-ip/" />
</template>
<FormModal @success="handleRefresh" />
<Grid table-title="地区列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: 'IP 查询',
type: 'primary',
icon: ACTION_ICON.SEARCH,
onClick: handleQueryIp,
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,49 @@
<script lang="ts" setup>
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { getAreaByIp } from '#/api/system/area';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const [Form, { setFieldValue, validate, getValues }] = 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 validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = await getValues();
try {
const result = await getAreaByIp(data.ip);
// 设置结果
await setFieldValue('result', result);
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal title="IP 查询">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,165 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemDeptApi } from '#/api/system/dept';
import type { SystemUserApi } from '#/api/system/user';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { handleTree } from '@vben/utils';
import { z } from '#/adapter/form';
import { getDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
let userList: SystemUserApi.User[] = [];
async function getUserData() {
userList = await getSimpleUserList();
}
getUserData();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'parentId',
label: '上级部门',
component: 'ApiTreeSelect',
componentProps: {
clearable: true,
api: async () => {
const data = await getDeptList();
data.unshift({
id: 0,
name: '顶级部门',
});
return handleTree(data);
},
labelField: 'name',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择上级部门',
treeDefaultExpandAll: true,
},
rules: 'selectRequired',
},
{
fieldName: 'name',
label: '部门名称',
component: 'Input',
componentProps: {
placeholder: '请输入部门名称',
},
rules: 'required',
},
{
fieldName: 'sort',
label: '显示顺序',
component: 'InputNumber',
componentProps: {
min: 0,
placeholder: '请输入显示顺序',
},
rules: 'required',
},
{
fieldName: 'leaderUserId',
label: '负责人',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
placeholder: '请选择负责人',
clearable: true,
},
rules: z.number().optional(),
},
{
fieldName: 'phone',
label: '联系电话',
component: 'Input',
componentProps: {
maxLength: 11,
placeholder: '请输入联系电话',
},
rules: 'mobileRequired',
},
{
fieldName: 'email',
label: '邮箱',
component: 'Input',
componentProps: {
placeholder: '请输入邮箱',
},
rules: z.string().email('邮箱格式不正确').or(z.literal('')).optional(),
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<SystemDeptApi.Dept>['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'name',
title: '部门名称',
minWidth: 150,
align: 'left',
fixed: 'left',
treeNode: true,
},
{
field: 'leaderUserId',
title: '负责人',
minWidth: 150,
formatter: ({ cellValue }) =>
userList.find((user) => user.id === cellValue)?.nickname || '-',
},
{
field: 'sort',
title: '显示顺序',
minWidth: 100,
},
{
field: 'status',
title: '部门状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 260,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,195 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemDeptApi } from '#/api/system/dept';
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteDept, deleteDeptList, getDeptList } from '#/api/system/dept';
import { $t } from '#/locales';
import { useGridColumns } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 切换树形展开/收缩状态 */
const isExpanded = ref(true);
function handleExpand() {
isExpanded.value = !isExpanded.value;
gridApi.grid.setAllTreeExpand(isExpanded.value);
}
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建部门 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 添加下级部门 */
function handleAppend(row: SystemDeptApi.Dept) {
formModalApi.setData({ parentId: row.id }).open();
}
/** 编辑部门 */
function handleEdit(row: SystemDeptApi.Dept) {
formModalApi.setData(row).open();
}
/** 删除部门 */
async function handleDelete(row: SystemDeptApi.Dept) {
const hideLoading = message.loading(
$t('ui.actionMessage.deleting', [row.name]),
{
duration: 0,
},
);
try {
await deleteDept(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 批量删除部门 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading($t('ui.actionMessage.deletingBatch'), {
duration: 0,
});
try {
await deleteDeptList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemDeptApi.Dept[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async () => {
return await getDeptList();
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
treeConfig: {
parentField: 'parentId',
rowField: 'id',
transform: true,
expandAll: true,
reserve: true,
},
} as VxeTableGridOptions<SystemDeptApi.Dept>,
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: ['system:dept:create'],
onClick: handleCreate,
},
{
label: isExpanded ? '收缩' : '展开',
type: 'primary',
onClick: handleExpand,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'error',
icon: ACTION_ICON.DELETE,
auth: ['system:dept:delete'],
disabled: isEmpty(checkedIds),
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '新增下级',
type: 'primary',
text: true,
icon: ACTION_ICON.ADD,
auth: ['system:dept:create'],
onClick: handleAppend.bind(null, row),
},
{
label: $t('common.edit'),
type: 'primary',
text: true,
icon: ACTION_ICON.EDIT,
auth: ['system:dept:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'error',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:dept:delete'],
disabled: row.children && row.children.length > 0,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,83 @@
<script lang="ts" setup>
import type { SystemDeptApi } from '#/api/system/dept';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { createDept, getDept, updateDept } from '#/api/system/dept';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemDeptApi.Dept>();
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 SystemDeptApi.Dept;
try {
await (formData.value?.id ? updateDept(data) : createDept(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<SystemDeptApi.Dept>();
if (!data || !data.id) {
// 设置上级
await formApi.setValues(data);
return;
}
modalApi.lock();
try {
formData.value = await getDept(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,355 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getSimpleDictTypeList } from '#/api/system/dict/type';
// ============================== 字典类型 ==============================
/** 类型新增/修改的表单 */
export function useTypeFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '字典名称',
component: 'Input',
componentProps: {
placeholder: '请输入字典名称',
},
rules: 'required',
},
{
fieldName: 'type',
label: '字典类型',
component: 'Input',
componentProps: (values) => {
return {
placeholder: '请输入字典类型',
disabled: !!values.id,
};
},
rules: 'required',
dependencies: {
triggerFields: [''],
},
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'remark',
label: '备注',
component: 'Input',
componentProps: {
type: 'textarea',
placeholder: '请输入备注',
},
},
];
}
/** 类型列表的搜索表单 */
export function useTypeGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '字典名称',
component: 'Input',
componentProps: {
placeholder: '请输入字典名称',
clearable: true,
},
},
{
fieldName: 'type',
label: '字典类型',
component: 'Input',
componentProps: {
placeholder: '请输入字典类型',
clearable: true,
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择状态',
clearable: true,
},
},
];
}
/** 类型列表的字段 */
export function useTypeGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '字典编号',
minWidth: 100,
},
{
field: 'name',
title: '字典名称',
minWidth: 200,
},
{
field: 'type',
title: '字典类型',
minWidth: 220,
},
{
field: 'status',
title: '状态',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'remark',
title: '备注',
minWidth: 180,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
minWidth: 140,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
// ============================== 字典数据 ==============================
// TODO @芋艿:后续针对 antd增加
/**
* 颜色选项
*/
const colorOptions = [
{ value: '', label: '无' },
{ value: 'processing', label: '主要' },
{ value: 'success', label: '成功' },
{ value: 'default', label: '默认' },
{ value: 'warning', label: '警告' },
{ value: 'error', label: '危险' },
{ value: 'pink', label: 'pink' },
{ value: 'red', label: 'red' },
{ value: 'orange', label: 'orange' },
{ value: 'green', label: 'green' },
{ value: 'cyan', label: 'cyan' },
{ value: 'blue', label: 'blue' },
{ value: 'purple', label: 'purple' },
];
/** 数据新增/修改的表单 */
export function useDataFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'dictType',
label: '字典类型',
component: 'ApiSelect',
componentProps: (values) => {
return {
api: getSimpleDictTypeList,
placeholder: '请输入字典类型',
labelField: 'name',
valueField: 'type',
disabled: !!values.id,
};
},
rules: 'required',
dependencies: {
triggerFields: [''],
},
},
{
fieldName: 'label',
label: '数据标签',
component: 'Input',
componentProps: {
placeholder: '请输入数据标签',
},
rules: 'required',
},
{
fieldName: 'value',
label: '数据键值',
component: 'Input',
componentProps: {
placeholder: '请输入数据键值',
},
rules: 'required',
},
{
fieldName: 'sort',
label: '显示排序',
component: 'InputNumber',
componentProps: {
placeholder: '请输入显示排序',
},
rules: 'required',
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择状态',
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'colorType',
label: '颜色类型',
component: 'Select',
componentProps: {
options: colorOptions,
placeholder: '请选择颜色类型',
},
},
{
fieldName: 'cssClass',
label: 'CSS Class',
component: 'Input',
componentProps: {
placeholder: '请输入 CSS Class',
},
help: '输入 hex 模式的颜色, 例如 #108ee9',
},
{
fieldName: 'remark',
label: '备注',
component: 'Input',
componentProps: {
type: 'textarea',
placeholder: '请输入备注',
},
},
];
}
/** 字典数据列表搜索表单 */
export function useDataGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'label',
label: '字典标签',
component: 'Input',
componentProps: {
placeholder: '请输入字典标签',
clearable: true,
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择状态',
clearable: true,
},
},
];
}
/** 字典数据表格列 */
export function useDataGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '字典编码',
minWidth: 100,
},
{
field: 'label',
title: '字典标签',
minWidth: 180,
},
{
field: 'value',
title: '字典键值',
minWidth: 100,
},
{
field: 'sort',
title: '字典排序',
minWidth: 100,
},
{
field: 'status',
title: '状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'colorType',
title: '颜色类型',
minWidth: 120,
slots: { default: 'colorType' },
},
{
field: 'cssClass',
title: 'CSS Class',
minWidth: 120,
slots: { default: 'cssClass' },
},
{
title: '创建时间',
field: 'createTime',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
minWidth: 140,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,33 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { DocAlert, Page } from '@vben/common-ui';
import DataGrid from './modules/data-grid.vue';
import TypeGrid from './modules/type-grid.vue';
const searchDictType = ref<string>(); // 搜索的字典类型
function handleDictTypeSelect(dictType: string) {
searchDictType.value = dictType;
}
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="字典管理" url="https://doc.iocoder.cn/system-dict/" />
</template>
<div class="flex h-full">
<!-- 左侧字典类型列表 -->
<div class="w-1/2 pr-3">
<TypeGrid @select="handleDictTypeSelect" />
</div>
<!-- 右侧字典数据列表 -->
<div class="w-1/2">
<DataGrid :dict-type="searchDictType" />
</div>
</div>
</Page>
</template>

View File

@@ -0,0 +1,89 @@
<script lang="ts" setup>
import type { SystemDictDataApi } from '#/api/system/dict/data';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import {
createDictData,
getDictData,
updateDictData,
} from '#/api/system/dict/data';
import { $t } from '#/locales';
import { useDataFormSchema } from '../data';
defineOptions({ name: 'SystemDictDataForm' });
const emit = defineEmits(['success']);
const formData = ref<SystemDictDataApi.DictData>();
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: 90,
},
layout: 'horizontal',
schema: useDataFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as SystemDictDataApi.DictData;
try {
await (formData.value?.id ? updateDictData(data) : createDictData(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<SystemDictDataApi.DictData>();
if (!data || !data.id) {
// 设置 dictType
await formApi.setValues(data);
return;
}
modalApi.lock();
try {
formData.value = await getDictData(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,208 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemDictDataApi } from '#/api/system/dict/data';
import { ref, watch } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDictData,
deleteDictDataList,
exportDictData,
getDictDataPage,
} from '#/api/system/dict/data';
import { $t } from '#/locales';
import { useDataGridColumns, useDataGridFormSchema } from '../data';
import DataForm from './data-form.vue';
const props = defineProps({
dictType: {
type: String,
default: undefined,
},
});
const [DataFormModal, dataFormModalApi] = useVbenModal({
connectedComponent: DataForm,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportDictData(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '字典数据.xls', source: data });
}
/** 创建字典数据 */
function handleCreate() {
dataFormModalApi.setData({ dictType: props.dictType }).open();
}
/** 编辑字典数据 */
function handleEdit(row: SystemDictDataApi.DictData) {
dataFormModalApi.setData(row).open();
}
/** 删除字典数据 */
async function handleDelete(row: SystemDictDataApi.DictData) {
const hideLoading = message.loading(
$t('ui.actionMessage.deleting', [row.label]),
{ duration: 0 },
);
try {
await deleteDictData(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.label]));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 批量删除字典数据 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading($t('ui.actionMessage.deleting'), {
duration: 0,
});
try {
await deleteDictDataList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemDictDataApi.DictData[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useDataGridFormSchema(),
},
gridOptions: {
columns: useDataGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDictDataPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
dictType: props.dictType,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemDictDataApi.DictData>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
/** 监听 dictType 变化,重新查询 */
watch(
() => props.dictType,
() => {
if (props.dictType) {
handleRefresh();
}
},
);
</script>
<template>
<div class="flex h-full flex-col">
<DataFormModal @success="handleRefresh" />
<Grid table-title="字典数据列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['字典数据']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:dict:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['system:dict:export'],
onClick: handleExport,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'error',
icon: ACTION_ICON.DELETE,
disabled: isEmpty(checkedIds),
auth: ['system:dict:delete'],
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #colorType="{ row }">
<Tag :color="row.colorType">{{ row.colorType }}</Tag>
</template>
<template #cssClass="{ row }">
<Tag :color="row.cssClass">{{ row.cssClass }}</Tag>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
text: true,
icon: ACTION_ICON.EDIT,
auth: ['system:dict:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'error',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:dict:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.label]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</div>
</template>

View File

@@ -0,0 +1,85 @@
<script lang="ts" setup>
import type { SystemDictTypeApi } from '#/api/system/dict/type';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import {
createDictType,
getDictType,
updateDictType,
} from '#/api/system/dict/type';
import { $t } from '#/locales';
import { useTypeFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemDictTypeApi.DictType>();
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: useTypeFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as SystemDictTypeApi.DictType;
try {
await (formData.value?.id ? updateDictType(data) : createDictType(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<SystemDictTypeApi.DictType>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getDictType(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,189 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemDictTypeApi } from '#/api/system/dict/type';
import { ref } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDictType,
deleteDictTypeList,
exportDictType,
getDictTypePage,
} from '#/api/system/dict/type';
import { $t } from '#/locales';
import { useTypeGridColumns, useTypeGridFormSchema } from '../data';
import TypeForm from './type-form.vue';
const emit = defineEmits(['select']);
const [TypeFormModal, typeFormModalApi] = useVbenModal({
connectedComponent: TypeForm,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportDictType(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '字典类型.xls', source: data });
}
/** 创建字典类型 */
function handleCreate() {
typeFormModalApi.setData(null).open();
}
/** 编辑字典类型 */
function handleEdit(row: SystemDictTypeApi.DictType) {
typeFormModalApi.setData(row).open();
}
/** 删除字典类型 */
async function handleDelete(row: SystemDictTypeApi.DictType) {
const hideLoading = message.loading(
$t('ui.actionMessage.deleting', [row.name]),
{ duration: 0 },
);
try {
await deleteDictType(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 批量删除字典类型 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading($t('ui.actionMessage.deletingBatch'), {
duration: 0,
});
try {
await deleteDictTypeList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'), { duration: 0 });
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemDictTypeApi.DictType[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useTypeGridFormSchema(),
},
gridOptions: {
columns: useTypeGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDictTypePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isCurrent: true,
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemDictTypeApi.DictType>,
gridEvents: {
cellClick: ({ row }: { row: SystemDictTypeApi.DictType }) => {
emit('select', row.type);
},
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<div class="h-full">
<TypeFormModal @success="handleRefresh" />
<Grid table-title="字典类型列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['字典类型']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:dict:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['system:dict:export'],
onClick: handleExport,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'error',
icon: ACTION_ICON.DELETE,
disabled: isEmpty(checkedIds),
auth: ['system:dict:delete'],
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
text: true,
icon: ACTION_ICON.EDIT,
auth: ['system:dict:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'error',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:dict:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</div>
</template>

View File

@@ -0,0 +1,150 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemLoginLogApi } from '#/api/system/login-log';
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
import { getRangePickerDefaultProps } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'username',
label: '用户名称',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入用户名称',
},
},
{
fieldName: 'userIp',
label: '登录地址',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入登录地址',
},
},
{
fieldName: 'createTime',
label: '登录时间',
component: 'DatePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '日志编号',
minWidth: 100,
},
{
field: 'logType',
title: '操作类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_LOGIN_TYPE },
},
},
{
field: 'username',
title: '用户名称',
minWidth: 180,
},
{
field: 'userIp',
title: '登录地址',
minWidth: 180,
},
{
field: 'userAgent',
title: '浏览器',
minWidth: 200,
},
{
field: 'result',
title: '登录结果',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_LOGIN_RESULT },
},
},
{
field: 'createTime',
title: '登录日期',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 120,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 详情页的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'id',
label: '日志编号',
},
{
field: 'logType',
label: '操作类型',
content: (data: SystemLoginLogApi.LoginLog) => {
return h(DictTag, {
type: DICT_TYPE.SYSTEM_LOGIN_TYPE,
value: data?.logType,
});
},
},
{
field: 'username',
label: '用户名称',
},
{
field: 'userIp',
label: '登录地址',
},
{
field: 'userAgent',
label: '浏览器',
},
{
field: 'result',
label: '登录结果',
content: (data: SystemLoginLogApi.LoginLog) => {
return h(DictTag, {
type: DICT_TYPE.SYSTEM_LOGIN_RESULT,
value: data?.result,
});
},
},
{
field: 'createTime',
label: '登录日期',
content: (data: SystemLoginLogApi.LoginLog) => {
return formatDateTime(data?.createTime || '') as string;
},
},
];
}

View File

@@ -0,0 +1,104 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemLoginLogApi } from '#/api/system/login-log';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { exportLoginLog, getLoginLogPage } from '#/api/system/login-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 exportLoginLog(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '登录日志.xls', source: data });
}
/** 查看登录日志详情 */
function handleDetail(row: SystemLoginLogApi.LoginLog) {
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 getLoginLogPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemLoginLogApi.LoginLog>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="系统日志" url="https://doc.iocoder.cn/system-log/" />
</template>
<DetailModal @success="handleRefresh" />
<Grid table-title="登录日志列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['system:login-log:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.detail'),
type: 'primary',
text: true,
icon: ACTION_ICON.VIEW,
auth: ['system:login-log:query'],
onClick: handleDetail.bind(null, row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,53 @@
<script lang="ts" setup>
import type { SystemLoginLogApi } from '#/api/system/login-log';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useDescription } from '#/components/description';
import { useDetailSchema } from '../data';
const formData = ref<SystemLoginLogApi.LoginLog>();
const [Descriptions] = useDescription({
componentProps: {
bordered: true,
column: 1,
contentClass: 'mx-4',
},
schema: useDetailSchema(),
});
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<SystemLoginLogApi.LoginLog>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = data;
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="登录日志详情"
class="w-1/2"
:show-cancel-button="false"
:show-confirm-button="false"
>
<Descriptions :data="formData" />
</Modal>
</template>

View File

@@ -0,0 +1,185 @@
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 { z } from '#/adapter/form';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'mail',
label: '邮箱',
component: 'Input',
componentProps: {
placeholder: '请输入邮箱',
},
rules: 'required',
},
{
fieldName: 'username',
label: '用户名',
component: 'Input',
componentProps: {
placeholder: '请输入用户名',
},
rules: 'required',
},
{
fieldName: 'password',
label: '密码',
component: 'InputPassword',
componentProps: {
placeholder: '请输入密码',
},
rules: 'required',
},
{
fieldName: 'host',
label: 'SMTP 服务器域名',
component: 'Input',
componentProps: {
placeholder: '请输入 SMTP 服务器域名',
},
rules: 'required',
},
{
fieldName: 'port',
label: 'SMTP 服务器端口',
component: 'InputNumber',
componentProps: {
placeholder: '请输入 SMTP 服务器端口',
min: 0,
max: 65_535,
},
rules: 'required',
},
{
fieldName: 'sslEnable',
label: '是否开启 SSL',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.boolean().default(true),
},
{
fieldName: 'starttlsEnable',
label: '是否开启 STARTTLS',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.boolean().default(false),
},
{
fieldName: 'remark',
label: '备注',
component: 'Input',
componentProps: {
type: 'textarea',
placeholder: '请输入备注',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'mail',
label: '邮箱',
component: 'Input',
componentProps: {
placeholder: '请输入邮箱',
clearable: true,
},
},
{
fieldName: 'username',
label: '用户名',
component: 'Input',
componentProps: {
placeholder: '请输入用户名',
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '编号',
minWidth: 100,
},
{
field: 'mail',
title: '邮箱',
minWidth: 160,
},
{
field: 'username',
title: '用户名',
minWidth: 160,
},
{
field: 'host',
title: 'SMTP 服务器域名',
minWidth: 150,
},
{
field: 'port',
title: 'SMTP 服务器端口',
minWidth: 130,
},
{
field: 'sslEnable',
title: '是否开启 SSL',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'starttlsEnable',
title: '是否开启 STARTTLS',
minWidth: 145,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,172 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemMailAccountApi } from '#/api/system/mail/account';
import { ref } from 'vue';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteMailAccount,
deleteMailAccountList,
getMailAccountPage,
} from '#/api/system/mail/account';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建邮箱账号 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑邮箱账号 */
function handleEdit(row: SystemMailAccountApi.MailAccount) {
formModalApi.setData(row).open();
}
/** 删除邮箱账号 */
async function handleDelete(row: SystemMailAccountApi.MailAccount) {
const hideLoading = message.loading(
$t('ui.actionMessage.deleting', [row.mail]),
{ duration: 0 },
);
try {
await deleteMailAccount(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.mail]));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 批量删除邮箱账号 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading($t('ui.actionMessage.deletingBatch'), {
duration: 0,
});
try {
await deleteMailAccountList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'), { duration: 0 });
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemMailAccountApi.MailAccount[];
}) {
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 getMailAccountPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemMailAccountApi.MailAccount>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="邮件配置" url="https://doc.iocoder.cn/mail" />
</template>
<FormModal @success="handleRefresh" />
<Grid table-title="邮箱账号列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['邮箱账号']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:mail-account:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'error',
icon: ACTION_ICON.DELETE,
auth: ['system:mail-account:delete'],
disabled: isEmpty(checkedIds),
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
text: true,
icon: ACTION_ICON.EDIT,
auth: ['system:mail-account:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'error',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:mail-account:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.mail]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,88 @@
<script lang="ts" setup>
import type { SystemMailAccountApi } from '#/api/system/mail/account';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import {
createMailAccount,
getMailAccount,
updateMailAccount,
} from '#/api/system/mail/account';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemMailAccountApi.MailAccount>();
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: 140,
},
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 SystemMailAccountApi.MailAccount;
try {
await (formData.value?.id
? updateMailAccount(data)
: createMailAccount(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<SystemMailAccountApi.MailAccount>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getMailAccount(data.id);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-1/3">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,262 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemMailLogApi } from '#/api/system/mail/log';
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { formatDateTime } from '@vben/utils';
import { getSimpleMailAccountList } from '#/api/system/mail/account';
import { DictTag } from '#/components/dict-tag';
import { getRangePickerDefaultProps } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'sendTime',
label: '发送时间',
component: 'DatePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
{
fieldName: 'userId',
label: '用户编号',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入用户编号',
},
},
{
fieldName: 'userType',
label: '用户类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.USER_TYPE, 'number'),
clearable: true,
placeholder: '请选择用户类型',
},
},
{
fieldName: 'sendStatus',
label: '发送状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_MAIL_SEND_STATUS, 'number'),
clearable: true,
placeholder: '请选择发送状态',
},
},
{
fieldName: 'accountId',
label: '邮箱账号',
component: 'ApiSelect',
componentProps: {
api: async () => await getSimpleMailAccountList(),
labelField: 'mail',
valueField: 'id',
clearable: true,
placeholder: '请选择邮箱账号',
},
},
{
fieldName: 'templateId',
label: '模板编号',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入模板编号',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 100,
},
{
field: 'sendTime',
title: '发送时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'userType',
title: '接收用户',
minWidth: 150,
slots: { default: 'userInfo' },
},
{
field: 'toMails',
title: '接收信息',
minWidth: 300,
formatter: ({ row }) => {
const lines: string[] = [];
if (row.toMails && row.toMails.length > 0) {
lines.push(`收件:${row.toMails.join('、')}`);
}
if (row.ccMails && row.ccMails.length > 0) {
lines.push(`抄送:${row.ccMails.join('、')}`);
}
if (row.bccMails && row.bccMails.length > 0) {
lines.push(`密送:${row.bccMails.join('、')}`);
}
return lines.join('\n');
},
},
{
field: 'templateTitle',
title: '邮件标题',
minWidth: 120,
},
{
field: 'templateContent',
title: '邮件内容',
minWidth: 300,
},
{
field: 'fromMail',
title: '发送邮箱',
minWidth: 120,
},
{
field: 'sendStatus',
title: '发送状态',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_MAIL_SEND_STATUS },
},
},
{
field: 'templateCode',
title: '模板编码',
minWidth: 120,
},
{
title: '操作',
width: 80,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 详情页的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'id',
label: '编号',
},
{
field: 'createTime',
label: '创建时间',
content: (data: SystemMailLogApi.MailLog) => {
return formatDateTime(data?.createTime || '') as string;
},
},
{
field: 'fromMail',
label: '发送邮箱',
},
{
field: 'userId',
label: '接收用户',
content: (data: SystemMailLogApi.MailLog) => {
if (data?.userType && data?.userId) {
return h('div', [
h(DictTag, {
type: DICT_TYPE.USER_TYPE,
value: data.userType,
}),
` (${data.userId})`,
]);
}
return '无';
},
},
{
field: 'toMails',
label: '接收信息',
content: (data: SystemMailLogApi.MailLog) => {
const lines: string[] = [];
if (data?.toMails && data.toMails.length > 0) {
lines.push(`收件:${data.toMails.join('、')}`);
}
if (data?.ccMails && data.ccMails.length > 0) {
lines.push(`抄送:${data.ccMails.join('、')}`);
}
if (data?.bccMails && data.bccMails.length > 0) {
lines.push(`密送:${data.bccMails.join('、')}`);
}
return h(
'div',
{
style: { whiteSpace: 'pre-line' },
},
lines.join('\n'),
);
},
},
{
field: 'templateId',
label: '模板编号',
},
{
field: 'templateCode',
label: '模板编码',
},
{
field: 'templateTitle',
label: '邮件标题',
},
{
field: 'templateContent',
label: '邮件内容',
span: 2,
content: (data: SystemMailLogApi.MailLog) => {
return h('div', {
innerHTML: data?.templateContent || '',
});
},
},
{
field: 'sendStatus',
label: '发送状态',
content: (data: SystemMailLogApi.MailLog) => {
return h(DictTag, {
type: DICT_TYPE.SYSTEM_MAIL_SEND_STATUS,
value: data?.sendStatus,
});
},
},
{
field: 'sendTime',
label: '发送时间',
content: (data: SystemMailLogApi.MailLog) => {
return formatDateTime(data?.sendTime || '') as string;
},
},
{
field: 'sendMessageId',
label: '发送消息编号',
},
{
field: 'sendException',
label: '发送异常',
},
];
}

View File

@@ -0,0 +1,91 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemMailLogApi } from '#/api/system/mail/log';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getMailLogPage } from '#/api/system/mail/log';
import { DictTag } from '#/components/dict-tag';
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();
}
/** 查看邮件日志 */
function handleDetail(row: SystemMailLogApi.MailLog) {
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 getMailLogPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemMailLogApi.MailLog>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="邮件配置" url="https://doc.iocoder.cn/mail" />
</template>
<DetailModal @success="handleRefresh" />
<Grid table-title="邮件日志列表">
<template #userInfo="{ row }">
<div v-if="row.userType && row.userId" class="flex items-center gap-1">
<DictTag :type="DICT_TYPE.USER_TYPE" :value="row.userType" />
<span>({{ row.userId }})</span>
</div>
<div v-else>-</div>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.detail'),
type: 'primary',
icon: ACTION_ICON.VIEW,
auth: ['system:mail-log:query'],
onClick: handleDetail.bind(null, row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,53 @@
<script lang="ts" setup>
import type { SystemMailLogApi } from '#/api/system/mail/log';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useDescription } from '#/components/description';
import { useDetailSchema } from '../data';
const formData = ref<SystemMailLogApi.MailLog>();
const [Descriptions] = useDescription({
componentProps: {
bordered: true,
column: 2,
contentClass: 'mx-4',
},
schema: useDetailSchema(),
});
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<SystemMailLogApi.MailLog>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = data;
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="邮件日志详情"
class="w-[1280px]"
:show-cancel-button="false"
:show-confirm-button="false"
>
<Descriptions :data="formData" />
</Modal>
</template>

View File

@@ -0,0 +1,267 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getSimpleMailAccountList } from '#/api/system/mail/account';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '模板名称',
component: 'Input',
componentProps: {
placeholder: '请输入模板名称',
},
rules: 'required',
},
{
fieldName: 'code',
label: '模板编码',
component: 'Input',
componentProps: {
placeholder: '请输入模板编码',
},
rules: 'required',
},
{
fieldName: 'accountId',
label: '邮箱账号',
component: 'ApiSelect',
componentProps: {
api: async () => await getSimpleMailAccountList(),
labelField: 'mail',
valueField: 'id',
placeholder: '请选择邮箱账号',
},
rules: 'required',
},
{
fieldName: 'nickname',
label: '发送人名称',
component: 'Input',
componentProps: {
placeholder: '请输入发送人名称',
},
},
{
fieldName: 'title',
label: '模板标题',
component: 'Input',
componentProps: {
placeholder: '请输入模板标题',
},
rules: 'required',
},
{
fieldName: 'content',
label: '模板内容',
component: 'RichTextarea',
rules: 'required',
},
{
fieldName: 'status',
label: '开启状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'remark',
label: '备注',
component: 'Input',
componentProps: {
type: 'textarea',
placeholder: '请输入备注',
},
},
];
}
/** 发送邮件表单 */
export function useSendMailFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'templateParams',
label: '模板参数',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'content',
label: '模板内容',
component: 'RichTextarea',
componentProps: {
options: {
readonly: true,
},
},
},
{
fieldName: 'toMails',
label: '收件邮箱',
component: 'Select',
componentProps: {
mode: 'tags',
clearable: true,
placeholder: '请输入收件邮箱,按 Enter 添加',
},
},
{
fieldName: 'ccMails',
label: '抄送邮箱',
component: 'Select',
componentProps: {
mode: 'tags',
clearable: true,
placeholder: '请输入抄送邮箱,按 Enter 添加',
},
},
{
fieldName: 'bccMails',
label: '密送邮箱',
component: 'Select',
componentProps: {
mode: 'tags',
clearable: true,
placeholder: '请输入密送邮箱,按 Enter 添加',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'status',
label: '开启状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
clearable: true,
placeholder: '请选择开启状态',
},
},
{
fieldName: 'code',
label: '模板编码',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入模板编码',
},
},
{
fieldName: 'name',
label: '模板名称',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入模板名称',
},
},
{
fieldName: 'accountId',
label: '邮箱账号',
component: 'ApiSelect',
componentProps: {
api: async () => await getSimpleMailAccountList(),
labelField: 'mail',
valueField: 'id',
clearable: true,
placeholder: '请选择邮箱账号',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'DatePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(
getAccountMail?: (accountId: number) => string | undefined,
): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '编号',
minWidth: 100,
},
{
field: 'code',
title: '模板编码',
minWidth: 120,
},
{
field: 'name',
title: '模板名称',
minWidth: 120,
},
{
field: 'title',
title: '模板标题',
minWidth: 120,
},
{
field: 'accountId',
title: '邮箱账号',
minWidth: 120,
formatter: ({ cellValue }) => getAccountMail?.(cellValue) || '-',
},
{
field: 'nickname',
title: '发送人名称',
minWidth: 120,
},
{
field: 'status',
title: '开启状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 220,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,205 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemMailAccountApi } from '#/api/system/mail/account';
import type { SystemMailTemplateApi } from '#/api/system/mail/template';
import { onMounted, ref } from 'vue';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getSimpleMailAccountList } from '#/api/system/mail/account';
import {
deleteMailTemplate,
deleteMailTemplateList,
getMailTemplatePage,
} from '#/api/system/mail/template';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import SendForm from './modules/send-form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [SendModal, sendModalApi] = useVbenModal({
connectedComponent: SendForm,
destroyOnClose: true,
});
/** 获取邮箱账号 */
const accountList = ref<SystemMailAccountApi.MailAccount[]>([]);
function getAccountMail(accountId: number) {
return accountList.value.find((account) => account.id === accountId)?.mail;
}
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建邮件模板 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑邮件模板 */
function handleEdit(row: SystemMailTemplateApi.MailTemplate) {
formModalApi.setData(row).open();
}
/** 发送测试邮件 */
function handleSend(row: SystemMailTemplateApi.MailTemplate) {
sendModalApi.setData(row).open();
}
/** 删除邮件模板 */
async function handleDelete(row: SystemMailTemplateApi.MailTemplate) {
const hideLoading = message.loading(
$t('ui.actionMessage.deleting', [row.name]),
{ duration: 0 },
);
try {
await deleteMailTemplate(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 批量删除邮件模板 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading($t('ui.actionMessage.deletingBatch'), {
duration: 0,
});
try {
await deleteMailTemplateList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'), { duration: 0 });
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemMailTemplateApi.MailTemplate[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(getAccountMail),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getMailTemplatePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemMailTemplateApi.MailTemplate>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
/** 初始化 */
onMounted(async () => {
accountList.value = await getSimpleMailAccountList();
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="邮件配置" url="https://doc.iocoder.cn/mail" />
</template>
<FormModal @success="handleRefresh" />
<SendModal />
<Grid table-title="邮件模板列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['邮件模板']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:mail-template:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'error',
icon: ACTION_ICON.DELETE,
auth: ['system:mail-template:delete'],
disabled: isEmpty(checkedIds),
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
text: true,
icon: ACTION_ICON.EDIT,
auth: ['system:mail-template:update'],
onClick: handleEdit.bind(null, row),
},
{
label: '测试',
type: 'primary',
text: true,
icon: ACTION_ICON.VIEW,
auth: ['system:mail-template:send-mail'],
onClick: handleSend.bind(null, row),
},
{
label: $t('common.delete'),
type: 'error',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:mail-template: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 { SystemMailTemplateApi } from '#/api/system/mail/template';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import {
createMailTemplate,
getMailTemplate,
updateMailTemplate,
} from '#/api/system/mail/template';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemMailTemplateApi.MailTemplate>();
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 SystemMailTemplateApi.MailTemplate;
try {
await (formData.value?.id
? updateMailTemplate(data)
: createMailTemplate(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<SystemMailTemplateApi.MailTemplate>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getMailTemplate(data.id);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-1/3">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,108 @@
<script lang="ts" setup>
import type { SystemMailTemplateApi } from '#/api/system/mail/template';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { sendMail } from '#/api/system/mail/template';
import { useSendMailFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemMailTemplateApi.MailTemplate>();
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 构建发送请求
const values = await formApi.getValues();
const paramsObj: Record<string, string> = {};
if (formData.value?.params) {
formData.value.params.forEach((param: string) => {
paramsObj[param] = values[`param_${param}`];
});
}
const data: SystemMailTemplateApi.MailSendReqVO = {
toMails: values.toMails,
ccMails: values.ccMails,
bccMails: values.bccMails,
templateCode: formData.value?.code || '',
templateParams: paramsObj,
};
// 提交表单
try {
await sendMail(data);
// 关闭并提示
await modalApi.close();
emit('success');
message.success('邮件发送成功');
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 获取数据
const data = modalApi.getData<SystemMailTemplateApi.MailTemplate>();
if (!data) {
return;
}
formData.value = data;
// 更新 form schema
const schema = buildFormSchema();
formApi.setState({ schema });
// 设置到 values
await formApi.setValues({
content: data.content,
});
},
});
/** 动态构建表单 schema */
function buildFormSchema() {
const schema = useSendMailFormSchema();
if (formData.value?.params) {
formData.value.params?.forEach((param: string) => {
schema.push({
fieldName: `param_${param}`,
label: `参数 ${param}`,
component: 'Input',
componentProps: {
placeholder: `请输入参数 ${param}`,
},
rules: 'required',
});
});
}
return schema;
}
</script>
<template>
<Modal title="测试发送邮件" class="w-1/3">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,324 @@
import type { Recordable } from '@vben/types';
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemMenuApi } from '#/api/system/menu';
import { h } from 'vue';
import {
CommonStatusEnum,
DICT_TYPE,
SystemMenuTypeEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { handleTree, isHttpUrl } from '@vben/utils';
import { z } from '#/adapter/form';
import { getMenuList } from '#/api/system/menu';
import { $t } from '#/locales';
import { componentKeys } from '#/router/routes';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'parentId',
label: '上级菜单',
component: 'ApiTreeSelect',
componentProps: {
clearable: true,
api: async () => {
const data = await getMenuList();
data.unshift({
id: 0,
name: '顶级部门',
} as SystemMenuApi.Menu);
return handleTree(data);
},
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);
},
};
},
},
{
fieldName: 'name',
label: '菜单名称',
component: 'Input',
componentProps: {
placeholder: '请输入菜单名称',
},
rules: 'required',
},
{
fieldName: 'type',
label: '菜单类型',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_MENU_TYPE, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(SystemMenuTypeEnum.DIR),
},
{
fieldName: 'icon',
label: '菜单图标',
component: 'IconPicker',
componentProps: {
placeholder: '请选择菜单图标',
prefix: 'carbon',
},
rules: 'required',
dependencies: {
triggerFields: ['type'],
show: (values) => {
return [SystemMenuTypeEnum.DIR, SystemMenuTypeEnum.MENU].includes(
values.type,
);
},
},
},
{
fieldName: 'path',
label: '路由地址',
component: 'Input',
componentProps: {
placeholder: '请输入路由地址',
},
rules: z.string(),
help: '访问的路由地址,如:`user`。如需外网地址时,则以 `http(s)://` 开头',
dependencies: {
triggerFields: ['type', 'parentId'],
show: (values) => {
return [SystemMenuTypeEnum.DIR, SystemMenuTypeEnum.MENU].includes(
values.type,
);
},
rules: (values) => {
const schema = z.string().min(1, '路由地址不能为空');
if (isHttpUrl(values.path)) {
return schema;
}
if (values.parentId === 0) {
return schema.refine(
(path) => path.charAt(0) === '/',
'路径必须以 / 开头',
);
}
return schema.refine(
(path) => path.charAt(0) !== '/',
'路径不能以 / 开头',
);
},
},
},
{
fieldName: 'component',
label: '组件地址',
component: 'Input',
componentProps: {
placeholder: '请输入组件地址',
},
dependencies: {
triggerFields: ['type'],
show: (values) => {
return [SystemMenuTypeEnum.MENU].includes(values.type);
},
},
},
{
fieldName: 'componentName',
label: '组件名称',
component: 'AutoComplete',
componentProps: {
clearable: true,
filterOption(input: string, option: { value: string }) {
return option.value.toLowerCase().includes(input.toLowerCase());
},
placeholder: '请选择组件名称',
options: componentKeys.map((v) => ({ value: v })),
},
dependencies: {
triggerFields: ['type'],
show: (values) => {
return [SystemMenuTypeEnum.MENU].includes(values.type);
},
},
},
{
fieldName: 'permission',
label: '权限标识',
component: 'Input',
componentProps: {
placeholder: '请输入菜单描述',
},
dependencies: {
show: (values) => {
return [SystemMenuTypeEnum.BUTTON, SystemMenuTypeEnum.MENU].includes(
values.type,
);
},
triggerFields: ['type'],
},
},
{
fieldName: 'sort',
label: '显示顺序',
component: 'InputNumber',
componentProps: {
min: 0,
placeholder: '请输入显示顺序',
},
rules: 'required',
},
{
fieldName: 'status',
label: '菜单状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'alwaysShow',
label: '总是显示',
component: 'RadioGroup',
componentProps: {
options: [
{ label: '总是', value: true },
{ label: '不是', value: false },
],
buttonStyle: 'solid',
optionType: 'button',
},
rules: 'required',
defaultValue: true,
help: '选择不是时,当该菜单只有一个子菜单时,不展示自己,直接展示子菜单',
dependencies: {
triggerFields: ['type'],
show: (values) => {
return [SystemMenuTypeEnum.MENU].includes(values.type);
},
},
},
{
fieldName: 'keepAlive',
label: '缓存状态',
component: 'RadioGroup',
componentProps: {
options: [
{ label: '缓存', value: true },
{ label: '不缓存', value: false },
],
buttonStyle: 'solid',
optionType: 'button',
},
rules: 'required',
defaultValue: true,
help: '选择缓存时,则会被 `keep-alive` 缓存,必须填写「组件名称」字段',
dependencies: {
triggerFields: ['type'],
show: (values) => {
return [SystemMenuTypeEnum.MENU].includes(values.type);
},
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<SystemMenuApi.Menu>['columns'] {
return [
{
field: 'name',
title: '菜单名称',
minWidth: 250,
align: 'left',
fixed: 'left',
slots: { default: 'name' },
treeNode: true,
},
{
field: 'type',
title: '菜单类型',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_MENU_TYPE },
},
},
{
field: 'sort',
title: '显示排序',
minWidth: 100,
},
{
field: 'permission',
title: '权限标识',
minWidth: 200,
},
{
field: 'path',
title: '组件路径',
minWidth: 200,
},
{
field: 'componentName',
title: '组件名称',
minWidth: 200,
},
{
field: 'status',
title: '状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
title: '操作',
width: 220,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,181 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemMenuApi } from '#/api/system/menu';
import { ref } from 'vue';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { SystemMenuTypeEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteMenu, getMenuList } from '#/api/system/menu';
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 handleAppend(row: SystemMenuApi.Menu) {
formModalApi.setData({ parentId: row.id }).open();
}
/** 编辑菜单 */
function handleEdit(row: SystemMenuApi.Menu) {
formModalApi.setData(row).open();
}
/** 删除菜单 */
async function handleDelete(row: SystemMenuApi.Menu) {
const hideLoading = message.loading(
$t('ui.actionMessage.deleting', [row.name]),
{ duration: 0 },
);
try {
await deleteMenu(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 切换树形展开/收缩状态 */
const isExpanded = ref(false);
function handleExpand() {
isExpanded.value = !isExpanded.value;
gridApi.grid.setAllTreeExpand(isExpanded.value);
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_params) => {
return await getMenuList();
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
},
treeConfig: {
parentField: 'parentId',
rowField: 'id',
transform: true,
reserve: true,
},
} as VxeTableGridOptions<SystemMenuApi.Menu>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="功能权限"
url="https://doc.iocoder.cn/resource-permission"
/>
<DocAlert title="菜单路由" url="https://doc.iocoder.cn/vue3/route/" />
</template>
<FormModal @success="handleRefresh" />
<Grid table-title="菜单列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['菜单']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:menu:create'],
onClick: handleCreate,
},
{
label: isExpanded ? '收缩' : '展开',
type: 'primary',
onClick: handleExpand,
},
]"
/>
</template>
<template #name="{ row }">
<div class="flex w-full items-center gap-1">
<div class="size-5 flex-shrink-0">
<IconifyIcon
v-if="row.type === SystemMenuTypeEnum.BUTTON"
icon="carbon:square-outline"
class="size-full"
/>
<IconifyIcon
v-else-if="row.icon"
:icon="row.icon || 'carbon:circle-dash'"
class="size-full"
/>
</div>
<span class="flex-auto">{{ $t(row.name) }}</span>
<div class="items-center justify-end"></div>
</div>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '新增下级',
type: 'primary',
text: true,
icon: ACTION_ICON.ADD,
auth: ['system:menu:create'],
onClick: handleAppend.bind(null, row),
},
{
label: $t('common.edit'),
type: 'primary',
text: true,
icon: ACTION_ICON.EDIT,
auth: ['system:menu:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'error',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:menu:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,83 @@
<script lang="ts" setup>
import type { SystemMenuApi } from '#/api/system/menu';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { createMenu, getMenu, updateMenu } from '#/api/system/menu';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemMenuApi.Menu>();
const getTitle = computed(() =>
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 SystemMenuApi.Menu;
try {
await (formData.value?.id ? updateMenu(data) : createMenu(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<SystemMenuApi.Menu>();
if (!data || !data.id) {
// 设置上级
await formApi.setValues(data);
return;
}
modalApi.lock();
try {
formData.value = await getMenu(data.id);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,136 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'title',
label: '公告标题',
component: 'Input',
rules: 'required',
},
{
fieldName: 'type',
label: '公告类型',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_NOTICE_TYPE, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: 'required',
},
{
fieldName: 'content',
label: '公告内容',
component: 'RichTextarea',
rules: 'required',
},
{
fieldName: 'status',
label: '公告状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'remark',
label: '备注',
component: 'Input',
componentProps: {
type: 'textarea',
placeholder: '请输入备注',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'title',
label: '公告标题',
component: 'Input',
componentProps: {
placeholder: '请输入公告标题',
clearable: true,
},
},
{
fieldName: 'status',
label: '公告状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择公告状态',
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '公告编号',
minWidth: 100,
},
{
field: 'title',
title: '公告标题',
minWidth: 200,
},
{
field: 'type',
title: '公告类型',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_NOTICE_TYPE },
},
},
{
field: 'status',
title: '公告状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 220,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,189 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemNoticeApi } from '#/api/system/notice';
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteNotice,
deleteNoticeList,
getNoticePage,
pushNotice,
} from '#/api/system/notice';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建公告 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑公告 */
function handleEdit(row: SystemNoticeApi.Notice) {
formModalApi.setData(row).open();
}
/** 删除公告 */
async function handleDelete(row: SystemNoticeApi.Notice) {
const hideLoading = message.loading(
$t('ui.actionMessage.deleting', [row.title]),
{ duration: 0 },
);
try {
await deleteNotice(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.title]));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 批量删除公告 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading($t('ui.actionMessage.deletingBatch'), {
duration: 0,
});
try {
await deleteNoticeList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemNoticeApi.Notice[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
/** 推送公告 */
async function handlePush(row: SystemNoticeApi.Notice) {
const hideLoading = message.loading('正在推送中...');
try {
await pushNotice(row.id!);
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
hideLoading.destroy();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getNoticePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemNoticeApi.Notice>,
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: ['system:notice:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'error',
icon: ACTION_ICON.DELETE,
auth: ['system:notice:delete'],
disabled: isEmpty(checkedIds),
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
text: true,
icon: ACTION_ICON.EDIT,
auth: ['system:notice:update'],
onClick: handleEdit.bind(null, row),
},
{
label: '推送',
type: 'primary',
text: true,
icon: ACTION_ICON.ADD,
auth: ['system:notice:update'],
onClick: handlePush.bind(null, row),
},
{
label: $t('common.delete'),
type: 'error',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:notice:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.title]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,81 @@
<script lang="ts" setup>
import type { SystemNoticeApi } from '#/api/system/notice';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { createNotice, getNotice, updateNotice } from '#/api/system/notice';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemNoticeApi.Notice>();
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 SystemNoticeApi.Notice;
try {
await (formData.value?.id ? updateNotice(data) : createNotice(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<SystemNoticeApi.Notice>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getNotice(data.id);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-1/2">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,242 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemNotifyMessageApi } from '#/api/system/notify/message';
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
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: {
clearable: true,
placeholder: '请输入用户编号',
},
},
{
fieldName: 'userType',
label: '用户类型',
component: 'Select',
componentProps: {
clearable: true,
options: getDictOptions(DICT_TYPE.USER_TYPE, 'number'),
placeholder: '请选择用户类型',
},
},
{
fieldName: 'templateCode',
label: '模板编码',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入模板编码',
},
},
{
fieldName: 'templateType',
label: '模版类型',
component: 'Select',
componentProps: {
options: getDictOptions(
DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE,
'number',
),
clearable: true,
placeholder: '请选择模版类型',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'DatePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 100,
},
{
field: 'userType',
title: '用户类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.USER_TYPE },
},
},
{
field: 'userId',
title: '用户编号',
minWidth: 100,
},
{
field: 'templateCode',
title: '模板编码',
minWidth: 120,
},
{
field: 'templateNickname',
title: '发送人名称',
minWidth: 180,
},
{
field: 'templateContent',
title: '模版内容',
minWidth: 200,
},
{
field: 'templateParams',
title: '模版参数',
minWidth: 180,
formatter: ({ cellValue }) => {
try {
return JSON.stringify(cellValue);
} catch {
return '';
}
},
},
{
field: 'templateType',
title: '模版类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE },
},
},
{
field: 'readStatus',
title: '是否已读',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'readTime',
title: '阅读时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 80,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 详情页的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'id',
label: '编号',
},
{
field: 'userType',
label: '用户类型',
content: (data: SystemNotifyMessageApi.NotifyMessage) => {
return h(DictTag, {
type: DICT_TYPE.USER_TYPE,
value: data?.userType,
});
},
},
{
field: 'userId',
label: '用户编号',
},
{
field: 'templateId',
label: '模版编号',
},
{
field: 'templateCode',
label: '模板编码',
},
{
field: 'templateNickname',
label: '发送人名称',
},
{
field: 'templateContent',
label: '模版内容',
},
{
field: 'templateParams',
label: '模版参数',
content: (data: SystemNotifyMessageApi.NotifyMessage) => {
try {
return JSON.stringify(data?.templateParams);
} catch {
return '';
}
},
},
{
field: 'templateType',
label: '模版类型',
content: (data: SystemNotifyMessageApi.NotifyMessage) => {
return h(DictTag, {
type: DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE,
value: data?.templateType,
});
},
},
{
field: 'readStatus',
label: '是否已读',
content: (data: SystemNotifyMessageApi.NotifyMessage) => {
return h(DictTag, {
type: DICT_TYPE.INFRA_BOOLEAN_STRING,
value: data?.readStatus,
});
},
},
{
field: 'readTime',
label: '阅读时间',
content: (data: SystemNotifyMessageApi.NotifyMessage) => {
return formatDateTime(data?.readTime || '') as string;
},
},
{
field: 'createTime',
label: '创建时间',
content: (data: SystemNotifyMessageApi.NotifyMessage) => {
return formatDateTime(data?.createTime || '') as string;
},
},
];
}

View File

@@ -0,0 +1,84 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemNotifyMessageApi } from '#/api/system/notify/message';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getNotifyMessagePage } from '#/api/system/notify/message';
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();
}
/** 查看站内信详情 */
function handleDetail(row: SystemNotifyMessageApi.NotifyMessage) {
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 getNotifyMessagePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemNotifyMessageApi.NotifyMessage>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="站内信" url="https://doc.iocoder.cn/notify/" />
</template>
<DetailModal @success="handleRefresh" />
<Grid table-title="站内信列表">
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.detail'),
type: 'primary',
text: true,
icon: ACTION_ICON.VIEW,
auth: ['system:notify-message:query'],
onClick: handleDetail.bind(null, row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,53 @@
<script lang="ts" setup>
import type { SystemNotifyMessageApi } from '#/api/system/notify/message';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useDescription } from '#/components/description';
import { useDetailSchema } from '../data';
const formData = ref<SystemNotifyMessageApi.NotifyMessage>();
const [Descriptions] = useDescription({
componentProps: {
bordered: true,
column: 1,
contentClass: 'mx-4',
},
schema: useDetailSchema(),
});
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<SystemNotifyMessageApi.NotifyMessage>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = data;
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="站内信详情"
class="w-1/3"
:show-cancel-button="false"
:show-confirm-button="false"
>
<Descriptions :data="formData" />
</Modal>
</template>

View File

@@ -0,0 +1,142 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemNotifyMessageApi } from '#/api/system/notify/message';
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
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: 'readStatus',
label: '是否已读',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
clearable: true,
placeholder: '请选择是否已读',
},
},
{
fieldName: 'createTime',
label: '发送时间',
component: 'DatePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
title: '',
width: 40,
type: 'checkbox',
},
{
field: 'templateNickname',
title: '发送人',
minWidth: 180,
},
{
field: 'createTime',
title: '发送时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'templateType',
title: '类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE },
},
},
{
field: 'templateContent',
title: '消息内容',
minWidth: 300,
},
{
field: 'readStatus',
title: '是否已读',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'readTime',
title: '阅读时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'templateNickname',
label: '发送人',
},
{
field: 'createTime',
label: '发送时间',
content: (data: SystemNotifyMessageApi.NotifyMessage) => {
return formatDateTime(data?.createTime || '') as string;
},
},
{
field: 'templateType',
label: '消息类型',
content: (data: SystemNotifyMessageApi.NotifyMessage) => {
return h(DictTag, {
type: DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE,
value: data?.templateType,
});
},
},
{
field: 'readStatus',
label: '是否已读',
content: (data: SystemNotifyMessageApi.NotifyMessage) => {
return h(DictTag, {
type: DICT_TYPE.INFRA_BOOLEAN_STRING,
value: data?.readStatus,
});
},
},
{
field: 'readTime',
label: '阅读时间',
content: (data: SystemNotifyMessageApi.NotifyMessage) => {
return formatDateTime(data?.readTime || '') as string;
},
},
{
field: 'templateContent',
label: '消息内容',
},
];
}

View File

@@ -0,0 +1,190 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemNotifyMessageApi } from '#/api/system/notify/message';
import { ref } from 'vue';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
getMyNotifyMessagePage,
updateAllNotifyMessageRead,
updateNotifyMessageRead,
} from '#/api/system/notify/message';
import { useGridColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue';
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 查看站内信详情 */
function handleDetail(row: SystemNotifyMessageApi.NotifyMessage) {
detailModalApi.setData(row).open();
}
/** 标记一条站内信已读 */
async function handleRead(row: SystemNotifyMessageApi.NotifyMessage) {
const hideLoading = message.loading('正在标记已读...', {
duration: 0,
});
try {
await updateNotifyMessageRead([row.id]);
message.success('标记已读成功');
handleRefresh();
// 打开详情
handleDetail(row);
} finally {
hideLoading.destroy();
}
}
/** 标记选中的站内信为已读 */
async function handleMarkRead() {
const rows = gridApi.grid.getCheckboxRecords();
if (!rows || rows.length === 0) {
message.warning('请选择需要标记的站内信');
return;
}
const ids = rows.map((row: SystemNotifyMessageApi.NotifyMessage) => row.id);
const hideLoading = message.loading('正在标记已读...', {
duration: 0,
});
try {
await updateNotifyMessageRead(ids);
checkedIds.value = [];
message.success('标记已读成功');
await gridApi.grid.setAllCheckboxRow(false);
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemNotifyMessageApi.NotifyMessage[];
}) {
checkedIds.value = records.map((item) => item.id);
}
/** 标记所有站内信为已读 */
async function handleMarkAllRead() {
const hideLoading = message.loading('正在标记全部已读...', {
duration: 0,
});
try {
await updateAllNotifyMessageRead();
message.success('全部标记已读成功');
checkedIds.value = [];
await gridApi.grid.setAllCheckboxRow(false);
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getMyNotifyMessagePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
checkboxConfig: {
checkMethod: (params: { row: SystemNotifyMessageApi.NotifyMessage }) =>
!params.row.readStatus,
highlight: true,
},
} as VxeTableGridOptions<SystemNotifyMessageApi.NotifyMessage>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="站内信配置" url="https://doc.iocoder.cn/notify/" />
</template>
<DetailModal @success="handleRefresh" />
<Grid table-title="我的站内信">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: '标记已读',
type: 'primary',
icon: ACTION_ICON.ADD,
disabled: isEmpty(checkedIds),
onClick: handleMarkRead,
},
{
label: '全部已读',
type: 'primary',
icon: ACTION_ICON.ADD,
onClick: handleMarkAllRead,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '查看',
type: 'primary',
text: true,
ifShow: row.readStatus,
icon: ACTION_ICON.VIEW,
onClick: handleDetail.bind(null, row),
},
{
label: '已读',
type: 'primary',
text: true,
ifShow: !row.readStatus,
icon: ACTION_ICON.ADD,
onClick: handleRead.bind(null, row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,53 @@
<script lang="ts" setup>
import type { SystemNotifyMessageApi } from '#/api/system/notify/message';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useDescription } from '#/components/description';
import { useDetailSchema } from '../data';
const formData = ref<SystemNotifyMessageApi.NotifyMessage>();
const [Descriptions] = useDescription({
componentProps: {
bordered: true,
column: 1,
contentClass: 'mx-4',
},
schema: useDetailSchema(),
});
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<SystemNotifyMessageApi.NotifyMessage>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = data;
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="站内信详情"
class="w-1/3"
:show-cancel-button="false"
:show-confirm-button="false"
>
<Descriptions :data="formData" />
</Modal>
</template>

View File

@@ -0,0 +1,291 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { CommonStatusEnum, DICT_TYPE, UserTypeEnum } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '模板名称',
component: 'Input',
componentProps: {
placeholder: '请输入模板名称',
},
rules: 'required',
},
{
fieldName: 'code',
label: '模板编码',
component: 'Input',
componentProps: {
placeholder: '请输入模板编码',
},
rules: 'required',
},
{
fieldName: 'nickname',
label: '发送人名称',
component: 'Input',
componentProps: {
placeholder: '请输入发送人名称',
},
rules: 'required',
},
{
fieldName: 'content',
label: '模板内容',
component: 'Input',
componentProps: {
type: 'textarea',
placeholder: '请输入模板内容',
},
rules: 'required',
},
{
fieldName: 'type',
label: '模板类型',
component: 'Select',
componentProps: {
options: getDictOptions(
DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE,
'number',
),
placeholder: '请选择模板类型',
},
rules: 'required',
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'remark',
label: '备注',
component: 'Input',
componentProps: {
type: 'textarea',
placeholder: '请输入备注',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '模板名称',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入模板名称',
},
},
{
fieldName: 'code',
label: '模板编码',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入模板编码',
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
clearable: true,
placeholder: '请选择状态',
},
},
{
fieldName: 'type',
label: '模板类型',
component: 'Select',
componentProps: {
options: getDictOptions(
DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE,
'number',
),
clearable: true,
placeholder: '请选择模板类型',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'DatePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 发送站内信表单 */
export function useSendNotifyFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'content',
label: '模板内容',
component: 'Input',
componentProps: {
type: 'textarea',
disabled: true,
},
},
{
fieldName: 'templateCode',
label: '模板编码',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'userType',
label: '用户类型',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.USER_TYPE, 'number'),
},
rules: z.number().default(UserTypeEnum.MEMBER),
},
{
fieldName: 'userId',
label: '接收人 ID',
component: 'Input',
componentProps: {
placeholder: '请输入用户编号',
},
dependencies: {
show(values) {
return values.userType === UserTypeEnum.MEMBER;
},
triggerFields: ['userType'],
},
rules: 'required',
},
{
fieldName: 'userId',
label: '接收人',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
placeholder: '请选择接收人',
},
dependencies: {
show(values) {
return values.userType === UserTypeEnum.ADMIN;
},
triggerFields: ['userType'],
},
rules: 'required',
},
{
fieldName: 'templateParams',
label: '模板参数',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '编号',
minWidth: 100,
},
{
field: 'name',
title: '模板名称',
minWidth: 120,
},
{
field: 'code',
title: '模板编码',
minWidth: 120,
},
{
field: 'nickname',
title: '发送人名称',
minWidth: 120,
},
{
field: 'content',
title: '模板内容',
minWidth: 200,
},
{
field: 'type',
title: '模板类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE },
},
},
{
field: 'status',
title: '状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'remark',
title: '备注',
minWidth: 120,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 220,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,209 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemNotifyTemplateApi } from '#/api/system/notify/template';
import { ref } from 'vue';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteNotifyTemplate,
deleteNotifyTemplateList,
exportNotifyTemplate,
getNotifyTemplatePage,
} from '#/api/system/notify/template';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import SendForm from './modules/send-form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [SendModal, sendModalApi] = useVbenModal({
connectedComponent: SendForm,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportNotifyTemplate(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '站内信模板.xls', source: data });
}
/** 创建站内信模板 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑站内信模板 */
function handleEdit(row: SystemNotifyTemplateApi.NotifyTemplate) {
formModalApi.setData(row).open();
}
/** 发送测试站内信 */
function handleSend(row: SystemNotifyTemplateApi.NotifyTemplate) {
sendModalApi.setData(row).open();
}
/** 删除站内信模板 */
async function handleDelete(row: SystemNotifyTemplateApi.NotifyTemplate) {
const hideLoading = message.loading(
$t('ui.actionMessage.deleting', [row.name]),
{
duration: 0,
},
);
try {
await deleteNotifyTemplate(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 批量删除站内信模板 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading($t('ui.actionMessage.deletingBatch'), {
duration: 0,
});
try {
await deleteNotifyTemplateList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemNotifyTemplateApi.NotifyTemplate[];
}) {
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 getNotifyTemplatePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemNotifyTemplateApi.NotifyTemplate>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="站内信" url="https://doc.iocoder.cn/notify/" />
</template>
<FormModal @success="handleRefresh" />
<SendModal />
<Grid table-title="站内信模板列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['站内信模板']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:notify-template:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['system:notify-template:export'],
onClick: handleExport,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'error',
icon: ACTION_ICON.DELETE,
disabled: isEmpty(checkedIds),
auth: ['system:notify-template:delete'],
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
text: true,
icon: ACTION_ICON.EDIT,
auth: ['system:notify-template:update'],
onClick: handleEdit.bind(null, row),
},
{
label: '测试',
type: 'primary',
text: true,
icon: ACTION_ICON.VIEW,
auth: ['system:notify-template:send-notify'],
onClick: handleSend.bind(null, row),
},
{
label: $t('common.delete'),
type: 'error',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:notify-template: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 { SystemNotifyTemplateApi } from '#/api/system/notify/template';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import {
createNotifyTemplate,
getNotifyTemplate,
updateNotifyTemplate,
} from '#/api/system/notify/template';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemNotifyTemplateApi.NotifyTemplate>();
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 SystemNotifyTemplateApi.NotifyTemplate;
try {
await (formData.value?.id
? updateNotifyTemplate(data)
: createNotifyTemplate(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<SystemNotifyTemplateApi.NotifyTemplate>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getNotifyTemplate(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,111 @@
<script lang="ts" setup>
import type { SystemNotifyTemplateApi } from '#/api/system/notify/template';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { sendNotify } from '#/api/system/notify/template';
import { $t } from '#/locales';
import { useSendNotifyFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemNotifyTemplateApi.NotifyTemplate>();
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 构建发送请求
const values = await formApi.getValues();
const paramsObj: Record<string, string> = {};
if (formData.value?.params) {
formData.value.params.forEach((param) => {
paramsObj[param] = values[`param_${param}`];
});
}
const data: SystemNotifyTemplateApi.NotifySendReqVO = {
userId: values.userId,
userType: values.userType,
templateCode: formData.value?.code || '',
templateParams: paramsObj,
};
// 提交表单
try {
await sendNotify(data);
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} catch (error) {
console.error('发送站内信失败', error);
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 获取数据
const data = modalApi.getData<SystemNotifyTemplateApi.NotifyTemplate>();
if (!data || !data.id) {
return;
}
formData.value = data;
// 更新 form schema
const schema = buildFormSchema();
formApi.setState({ schema });
// 设置到 values
await formApi.setValues({
content: data.content,
templateCode: data.code,
});
},
});
/** 动态构建表单 schema */
function buildFormSchema() {
const schema = useSendNotifyFormSchema();
if (formData.value?.params) {
formData.value.params.forEach((param: string) => {
schema.push({
fieldName: `param_${param}`,
label: `参数 ${param}`,
component: 'Input',
rules: 'required',
componentProps: {
placeholder: `请输入参数 ${param}`,
},
});
});
}
return schema;
}
</script>
<template>
<Modal title="测试发送站内信">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,265 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'clientId',
label: '客户端编号',
component: 'Input',
componentProps: {
placeholder: '请输入客户端编号',
},
rules: 'required',
},
{
fieldName: 'secret',
label: '客户端密钥',
component: 'Input',
componentProps: {
placeholder: '请输入客户端密钥',
},
rules: 'required',
},
{
fieldName: 'name',
label: '应用名',
component: 'Input',
componentProps: {
placeholder: '请输入应用名',
},
rules: 'required',
},
{
fieldName: 'logo',
label: '应用图标',
component: 'ImageUpload',
rules: 'required',
},
{
fieldName: 'description',
label: '应用描述',
component: 'Input',
componentProps: {
type: 'textarea',
placeholder: '请输入应用描述',
},
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'accessTokenValiditySeconds',
label: '访问令牌的有效期',
component: 'InputNumber',
componentProps: {
placeholder: '请输入访问令牌的有效期,单位:秒',
min: 0,
},
rules: 'required',
},
{
fieldName: 'refreshTokenValiditySeconds',
label: '刷新令牌的有效期',
component: 'InputNumber',
componentProps: {
placeholder: '请输入刷新令牌的有效期,单位:秒',
min: 0,
},
rules: 'required',
},
{
fieldName: 'authorizedGrantTypes',
label: '授权类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_OAUTH2_GRANT_TYPE),
mode: 'multiple',
placeholder: '请输入授权类型',
},
rules: 'required',
},
{
fieldName: 'scopes',
label: '授权范围',
component: 'Select',
componentProps: {
placeholder: '请输入授权范围',
mode: 'tags',
clearable: true,
},
},
{
fieldName: 'autoApproveScopes',
label: '自动授权范围',
component: 'Select',
componentProps: {
placeholder: '请输入自动授权范围',
mode: 'multiple',
},
dependencies: {
triggerFields: ['scopes'],
componentProps: (values) => ({
options: values.scopes
? values.scopes.map((scope: string) => ({
label: scope,
value: scope,
}))
: [],
}),
},
},
{
fieldName: 'redirectUris',
label: '可重定向的 URI 地址',
component: 'Select',
componentProps: {
placeholder: '请输入可重定向的 URI 地址',
mode: 'tags',
},
rules: 'required',
},
{
fieldName: 'authorities',
label: '权限',
component: 'Select',
componentProps: {
placeholder: '请输入权限',
mode: 'tags',
},
},
{
fieldName: 'resourceIds',
label: '资源',
component: 'Select',
componentProps: {
mode: 'tags',
placeholder: '请输入资源',
},
},
{
fieldName: 'additionalInformation',
label: '附加信息',
component: 'Input',
componentProps: {
type: 'textarea',
placeholder: '请输入附加信息JSON 格式数据',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '应用名',
component: 'Input',
componentProps: {
placeholder: '请输入应用名',
clearable: true,
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
clearable: true,
placeholder: '请输入状态',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'clientId',
title: '客户端编号',
minWidth: 120,
},
{
field: 'secret',
title: '客户端密钥',
minWidth: 120,
},
{
field: 'name',
title: '应用名',
minWidth: 120,
},
{
field: 'logo',
title: '应用图标',
minWidth: 100,
cellRender: {
name: 'CellImage',
},
},
{
field: 'status',
title: '状态',
minWidth: 80,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'accessTokenValiditySeconds',
title: '访问令牌的有效期',
minWidth: 150,
formatter: ({ cellValue }) => `${cellValue}`,
},
{
field: 'refreshTokenValiditySeconds',
title: '刷新令牌的有效期',
minWidth: 150,
formatter: ({ cellValue }) => `${cellValue}`,
},
{
field: 'authorizedGrantTypes',
title: '授权类型',
minWidth: 100,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,176 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemOAuth2ClientApi } from '#/api/system/oauth2/client';
import { ref } from 'vue';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteOAuth2Client,
deleteOAuth2ClientList,
getOAuth2ClientPage,
} from '#/api/system/oauth2/client';
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();
}
/** 创建 OAuth2 客户端 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑 OAuth2 客户端 */
function handleEdit(row: SystemOAuth2ClientApi.OAuth2Client) {
formModalApi.setData(row).open();
}
/** 删除 OAuth2 客户端 */
async function handleDelete(row: SystemOAuth2ClientApi.OAuth2Client) {
const hideLoading = message.loading(
$t('ui.actionMessage.deleting', [row.name]),
{ duration: 0 },
);
try {
await deleteOAuth2Client(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 批量删除 OAuth2 客户端 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading($t('ui.actionMessage.deletingBatch'), {
duration: 0,
});
try {
await deleteOAuth2ClientList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemOAuth2ClientApi.OAuth2Client[];
}) {
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 getOAuth2ClientPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemOAuth2ClientApi.OAuth2Client>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="OAuth 2.0SSO 单点登录)"
url="https://doc.iocoder.cn/oauth2/"
/>
</template>
<FormModal @success="handleRefresh" />
<Grid table-title="OAuth2 客户端列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', [' OAuth2.0 客户端']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:oauth2-client:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'error',
icon: ACTION_ICON.DELETE,
auth: ['system:oauth2-client:delete'],
disabled: isEmpty(checkedIds),
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
text: true,
icon: ACTION_ICON.EDIT,
auth: ['system:oauth2-client:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'error',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:oauth2-client: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 { SystemOAuth2ClientApi } from '#/api/system/oauth2/client';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import {
createOAuth2Client,
getOAuth2Client,
updateOAuth2Client,
} from '#/api/system/oauth2/client';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemOAuth2ClientApi.OAuth2Client>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', [' OAuth2.0 客户端'])
: $t('ui.actionTitle.create', [' OAuth2.0 客户端']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 140,
},
wrapperClass: 'grid-cols-2',
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 SystemOAuth2ClientApi.OAuth2Client;
try {
await (formData.value?.id
? updateOAuth2Client(data)
: createOAuth2Client(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<SystemOAuth2ClientApi.OAuth2Client>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getOAuth2Client(data.id);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-1/2" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,93 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'userId',
label: '用户编号',
component: 'Input',
componentProps: {
placeholder: '请输入用户编号',
clearable: true,
},
},
{
fieldName: 'userType',
label: '用户类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.USER_TYPE, 'number'),
placeholder: '请选择用户类型',
clearable: true,
},
},
{
fieldName: 'clientId',
label: '客户端编号',
component: 'Input',
componentProps: {
placeholder: '请输入客户端编号',
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'accessToken',
title: '访问令牌',
minWidth: 300,
},
{
field: 'refreshToken',
title: '刷新令牌',
minWidth: 300,
},
{
field: 'userId',
title: '用户编号',
minWidth: 100,
},
{
field: 'userType',
title: '用户类型',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.USER_TYPE },
},
},
{
field: 'clientId',
title: '客户端编号',
minWidth: 120,
},
{
field: 'expiresTime',
title: '过期时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 80,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,145 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemOAuth2TokenApi } from '#/api/system/oauth2/token';
import { ref } from 'vue';
import { confirm, DocAlert, Page } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteOAuth2Token,
getOAuth2TokenPage,
} from '#/api/system/oauth2/token';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 删除 OAuth2 令牌 */
async function handleDelete(row: SystemOAuth2TokenApi.OAuth2Token) {
const hideLoading = message.loading(
$t('ui.actionMessage.deleting', ['令牌']),
{
duration: 0,
},
);
try {
await deleteOAuth2Token(row.accessToken);
message.success($t('ui.actionMessage.deleteSuccess', ['令牌']));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 批量删除 OAuth2 令牌 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading($t('ui.actionMessage.deletingBatch'), {
duration: 0,
});
try {
await deleteOAuth2Token(checkedIds.value.join(','));
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<string[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemOAuth2TokenApi.OAuth2Token[];
}) {
checkedIds.value = records.map((item) => item.accessToken);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getOAuth2TokenPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'accessToken',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemOAuth2TokenApi.OAuth2Token>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="OAuth 2.0SSO 单点登录)"
url="https://doc.iocoder.cn/oauth2/"
/>
</template>
<Grid table-title="令牌列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'error',
icon: ACTION_ICON.DELETE,
auth: ['system:oauth2-token:delete'],
disabled: isEmpty(checkedIds),
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.delete'),
type: 'primary',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:oauth2-token:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', ['令牌']),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,195 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemOperateLogApi } from '#/api/system/operate-log';
import type { DescriptionItemSchema } from '#/components/description';
import { formatDateTime } from '@vben/utils';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'userId',
label: '操作人',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
fieldNames: {
label: 'nickname',
value: 'id',
},
clearable: true,
placeholder: '请选择操作人员',
},
},
{
fieldName: 'type',
label: '操作模块',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入操作模块',
},
},
{
fieldName: 'subType',
label: '操作名',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入操作名',
},
},
{
fieldName: 'action',
label: '操作内容',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入操作内容',
},
},
{
fieldName: 'createTime',
label: '操作时间',
component: 'DatePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
{
fieldName: 'bizId',
label: '业务编号',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入业务编号',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '日志编号',
minWidth: 100,
},
{
field: 'userName',
title: '操作人',
minWidth: 120,
},
{
field: 'type',
title: '操作模块',
minWidth: 120,
},
{
field: 'subType',
title: '操作名',
minWidth: 160,
},
{
field: 'action',
title: '操作内容',
minWidth: 200,
},
{
field: 'createTime',
title: '操作时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'bizId',
title: '业务编号',
minWidth: 120,
},
{
field: 'userIp',
title: '操作 IP',
minWidth: 120,
},
{
title: '操作',
width: 80,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 详情页的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'id',
label: '日志编号',
},
{
field: 'traceId',
label: '链路追踪',
hidden: (data: SystemOperateLogApi.OperateLog) => !data?.traceId,
},
{
field: 'userId',
label: '操作人编号',
},
{
field: 'userName',
label: '操作人名字',
},
{
field: 'userIp',
label: '操作人 IP',
},
{
field: 'userAgent',
label: '操作人 UA',
},
{
field: 'type',
label: '操作模块',
},
{
field: 'subType',
label: '操作名',
},
{
field: 'action',
label: '操作内容',
},
{
field: 'extra',
label: '操作拓展参数',
hidden: (data: SystemOperateLogApi.OperateLog) => !data?.extra,
},
{
label: '请求 URL',
content: (data: SystemOperateLogApi.OperateLog) => {
if (data?.requestMethod && data?.requestUrl) {
return `${data.requestMethod} ${data.requestUrl}`;
}
return '';
},
},
{
field: 'createTime',
label: '操作时间',
content: (data: SystemOperateLogApi.OperateLog) => {
return formatDateTime(data?.createTime || '') as string;
},
},
{
field: 'bizId',
label: '业务编号',
},
];
}

View File

@@ -0,0 +1,104 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemOperateLogApi } from '#/api/system/operate-log';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { exportOperateLog, getOperateLogPage } from '#/api/system/operate-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 exportOperateLog(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '操作日志.xls', source: data });
}
/** 查看操作日志详情 */
function handleDetail(row: SystemOperateLogApi.OperateLog) {
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 getOperateLogPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemOperateLogApi.OperateLog>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="系统日志" url="https://doc.iocoder.cn/system-log/" />
</template>
<DetailModal @success="handleRefresh" />
<Grid table-title="操作日志列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['system:operate-log:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.detail'),
type: 'primary',
text: true,
icon: ACTION_ICON.VIEW,
auth: ['system:operate-log:query'],
onClick: handleDetail.bind(null, row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,53 @@
<script lang="ts" setup>
import type { SystemOperateLogApi } from '#/api/system/operate-log';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useDescription } from '#/components/description';
import { useDetailSchema } from '../data';
const formData = ref<SystemOperateLogApi.OperateLog>();
const [Descriptions] = useDescription({
componentProps: {
bordered: true,
column: 1,
contentClass: 'mx-4',
},
schema: useDetailSchema(),
});
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<SystemOperateLogApi.OperateLog>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = data;
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="操作日志详情"
class="w-1/2"
:show-cancel-button="false"
:show-confirm-button="false"
>
<Descriptions :data="formData" />
</Modal>
</template>

View File

@@ -0,0 +1,148 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
component: 'Input',
fieldName: 'name',
label: '岗位名称',
rules: 'required',
},
{
component: 'Input',
fieldName: 'code',
label: '岗位编码',
rules: 'required',
},
{
fieldName: 'sort',
label: '显示顺序',
component: 'InputNumber',
componentProps: {
min: 0,
},
rules: 'required',
},
{
fieldName: 'status',
label: '岗位状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'remark',
label: '岗位备注',
component: 'Input',
componentProps: {
type: 'textarea',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '岗位名称',
component: 'Input',
componentProps: {
placeholder: '请输入岗位名称',
clearable: true,
},
},
{
fieldName: 'code',
label: '岗位编码',
component: 'Input',
componentProps: {
placeholder: '请输入岗位编码',
clearable: true,
},
},
{
fieldName: 'status',
label: '岗位状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择岗位状态',
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '岗位编号',
minWidth: 200,
},
{
field: 'name',
title: '岗位名称',
minWidth: 200,
},
{
field: 'code',
title: '岗位编码',
minWidth: 200,
},
{
field: 'sort',
title: '显示顺序',
minWidth: 100,
},
{
field: 'remark',
title: '岗位备注',
minWidth: 200,
},
{
field: 'status',
title: '岗位状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,183 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemPostApi } from '#/api/system/post';
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deletePost,
deletePostList,
exportPost,
getPostPage,
} from '#/api/system/post';
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 exportPost(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '岗位.xls', source: data });
}
/** 创建岗位 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑岗位 */
function handleEdit(row: SystemPostApi.Post) {
formModalApi.setData(row).open();
}
/** 删除岗位 */
async function handleDelete(row: SystemPostApi.Post) {
const hideLoading = message.loading(
$t('ui.actionMessage.deleting', [row.name]),
{ duration: 0 },
);
try {
await deletePost(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 批量删除岗位 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading($t('ui.actionMessage.deletingBatch'), {
duration: 0,
});
try {
await deletePostList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemPostApi.Post[];
}) {
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 getPostPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemPostApi.Post>,
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: ['system:post:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['system:post:export'],
onClick: handleExport,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'error',
icon: ACTION_ICON.DELETE,
auth: ['system:post:delete'],
disabled: isEmpty(checkedIds),
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
text: true,
icon: ACTION_ICON.EDIT,
auth: ['system:post:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'error',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:post: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 { SystemPostApi } from '#/api/system/post';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { createPost, getPost, updatePost } from '#/api/system/post';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemPostApi.Post>();
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 SystemPostApi.Post;
try {
await (formData.value?.id ? updatePost(data) : createPost(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<SystemPostApi.Post>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getPost(data.id);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-[600px]" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,258 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import {
CommonStatusEnum,
DICT_TYPE,
SystemDataScopeEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '角色名称',
component: 'Input',
rules: 'required',
},
{
fieldName: 'code',
label: '角色标识',
component: 'Input',
rules: 'required',
},
{
fieldName: 'sort',
label: '显示顺序',
component: 'InputNumber',
componentProps: {
min: 0,
placeholder: '请输入显示顺序',
},
rules: 'required',
},
{
fieldName: 'status',
label: '角色状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'remark',
label: '角色备注',
component: 'Input',
componentProps: {
type: 'textarea',
},
},
];
}
/** 分配数据权限的表单 */
export function useAssignDataPermissionFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '角色名称',
component: 'Input',
componentProps: {
disabled: true,
},
},
{
component: 'Input',
fieldName: 'code',
label: '角色标识',
componentProps: {
disabled: true,
},
},
{
component: 'Select',
fieldName: 'dataScope',
label: '权限范围',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_DATA_SCOPE, 'number'),
},
},
{
fieldName: 'dataScopeDeptIds',
label: '部门范围',
component: 'Input',
formItemClass: 'items-start',
dependencies: {
triggerFields: ['dataScope'],
show: (values) => {
return values.dataScope === SystemDataScopeEnum.DEPT_CUSTOM;
},
},
},
];
}
/** 分配菜单的表单 */
export function useAssignMenuFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '角色名称',
component: 'Input',
componentProps: {
disabled: true,
},
},
{
fieldName: 'code',
label: '角色标识',
component: 'Input',
componentProps: {
disabled: true,
},
},
{
fieldName: 'menuIds',
label: '菜单权限',
component: 'Input',
formItemClass: 'items-start',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '角色名称',
component: 'Input',
componentProps: {
placeholder: '请输入角色名称',
clearable: true,
},
},
{
fieldName: 'code',
label: '角色标识',
component: 'Input',
componentProps: {
placeholder: '请输入角色标识',
clearable: true,
},
},
{
fieldName: 'status',
label: '角色状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择角色状态',
clearable: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'DatePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '角色编号',
minWidth: 100,
},
{
field: 'name',
title: '角色名称',
minWidth: 200,
},
{
field: 'type',
title: '角色类型',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_ROLE_TYPE },
},
},
{
field: 'code',
title: '角色标识',
minWidth: 200,
},
{
field: 'sort',
title: '显示顺序',
minWidth: 100,
},
{
field: 'remark',
title: '角色备注',
minWidth: 100,
},
{
field: 'status',
title: '角色状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 240,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,232 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemRoleApi } from '#/api/system/role';
import { ref } from 'vue';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteRole,
deleteRoleList,
exportRole,
getRolePage,
} from '#/api/system/role';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import AssignDataPermissionForm from './modules/assign-data-permission-form.vue';
import AssignMenuForm from './modules/assign-menu-form.vue';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [AssignDataPermissionFormModel, assignDataPermissionFormApi] =
useVbenModal({
connectedComponent: AssignDataPermissionForm,
destroyOnClose: true,
});
const [AssignMenuFormModel, assignMenuFormApi] = useVbenModal({
connectedComponent: AssignMenuForm,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportRole(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '角色.xls', source: data });
}
/** 创建角色 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑角色 */
function handleEdit(row: SystemRoleApi.Role) {
formModalApi.setData(row).open();
}
/** 删除角色 */
async function handleDelete(row: SystemRoleApi.Role) {
const hideLoading = message.loading(
$t('ui.actionMessage.deleting', [row.name]),
{ duration: 0 },
);
try {
await deleteRole(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 批量删除角色 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading($t('ui.actionMessage.deletingBatch'), {
duration: 0,
});
try {
await deleteRoleList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemRoleApi.Role[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
/** 分配角色的数据权限 */
function handleAssignDataPermission(row: SystemRoleApi.Role) {
assignDataPermissionFormApi.setData(row).open();
}
/** 分配角色的菜单权限 */
function handleAssignMenu(row: SystemRoleApi.Role) {
assignMenuFormApi.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 getRolePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemRoleApi.Role>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="功能权限"
url="https://doc.iocoder.cn/resource-permission"
/>
<DocAlert title="数据权限" url="https://doc.iocoder.cn/data-permission" />
</template>
<FormModal @success="handleRefresh" />
<AssignDataPermissionFormModel @success="handleRefresh" />
<AssignMenuFormModel @success="handleRefresh" />
<Grid table-title="角色列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['角色']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:role:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['system:role:export'],
onClick: handleExport,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'error',
icon: ACTION_ICON.DELETE,
disabled: isEmpty(checkedIds),
auth: ['system:role:delete'],
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
text: true,
icon: ACTION_ICON.EDIT,
auth: ['system:role:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'error',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:role:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
:drop-down-actions="[
{
label: '数据权限',
type: 'primary',
text: true,
auth: ['system:permission:assign-role-data-scope'],
onClick: handleAssignDataPermission.bind(null, row),
},
{
label: '菜单权限',
type: 'primary',
text: true,
auth: ['system:permission:assign-role-menu'],
onClick: handleAssignMenu.bind(null, row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,166 @@
<script lang="ts" setup>
import type { SystemDeptApi } from '#/api/system/dept';
import type { SystemRoleApi } from '#/api/system/role';
import { ref } from 'vue';
import { Tree, useVbenModal } from '@vben/common-ui';
import { SystemDataScopeEnum } from '@vben/constants';
import { handleTree } from '@vben/utils';
import { NCheckbox, NSpin } from 'naive-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { getDeptList } from '#/api/system/dept';
import { assignRoleDataScope } from '#/api/system/permission';
import { getRole } from '#/api/system/role';
import { $t } from '#/locales';
import { useAssignDataPermissionFormSchema } from '../data';
const emit = defineEmits(['success']);
const deptTree = ref<SystemDeptApi.Dept[]>([]); // 部门树
const deptLoading = ref(false); // 加载部门列表
const isAllSelected = ref(false); // 全选状态
const isExpanded = ref(false); // 展开状态
const isCheckStrictly = ref(true); // 父子联动状态
const expandedKeys = ref<number[]>([]); // 展开的节点
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useAssignDataPermissionFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
const data = await formApi.getValues();
try {
await assignRoleDataScope({
roleId: data.id,
dataScope: data.dataScope,
dataScopeDeptIds:
data.dataScope === SystemDataScopeEnum.DEPT_CUSTOM
? data.dataScopeDeptIds
: undefined,
});
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
const data = modalApi.getData<SystemRoleApi.Role>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
// 加载部门列表
await loadDeptTree();
handleExpandAll();
// 设置表单值,一定要在加载树之后
await formApi.setValues(await getRole(data.id));
} finally {
modalApi.unlock();
}
},
});
/** 加载部门树 */
async function loadDeptTree() {
deptLoading.value = true;
try {
const data = await getDeptList();
deptTree.value = handleTree(data) as SystemDeptApi.Dept[];
} finally {
deptLoading.value = false;
}
}
/** 全选/全不选 */
function handleSelectAll() {
isAllSelected.value = !isAllSelected.value;
if (isAllSelected.value) {
const allIds = getAllNodeIds(deptTree.value);
formApi.setFieldValue('dataScopeDeptIds', allIds);
} else {
formApi.setFieldValue('dataScopeDeptIds', []);
}
}
/** 展开/折叠所有节点 */
function handleExpandAll() {
isExpanded.value = !isExpanded.value;
expandedKeys.value = isExpanded.value ? getAllNodeIds(deptTree.value) : [];
}
/** 切换父子联动 */
function handleCheckStrictly() {
isCheckStrictly.value = !isCheckStrictly.value;
}
/** 递归获取所有节点 ID */
function getAllNodeIds(nodes: any[], ids: number[] = []): number[] {
nodes.forEach((node: any) => {
ids.push(node.id);
if (node.children && node.children.length > 0) {
getAllNodeIds(node.children, ids);
}
});
return ids;
}
</script>
<template>
<Modal title="数据权限" class="w-2/5">
<Form class="mx-4">
<template #dataScopeDeptIds="slotProps">
<NSpin :show="deptLoading" class="w-full">
<Tree
:tree-data="deptTree"
multiple
bordered
:default-expanded-keys="expandedKeys"
v-bind="slotProps"
:check-strictly="!isCheckStrictly"
value-field="id"
label-field="name"
/>
</NSpin>
</template>
</Form>
<template #prepend-footer>
<div class="flex flex-auto items-center">
<NCheckbox :checked="isAllSelected" @change="handleSelectAll">
全选
</NCheckbox>
<NCheckbox :checked="isExpanded" @change="handleExpandAll">
全部展开
</NCheckbox>
<NCheckbox :checked="isCheckStrictly" @change="handleCheckStrictly">
父子联动
</NCheckbox>
</div>
</template>
</Modal>
</template>

View File

@@ -0,0 +1,171 @@
<script lang="ts" setup>
import type { Recordable } from '@vben/types';
import type { SystemMenuApi } from '#/api/system/menu';
import type { SystemRoleApi } from '#/api/system/role';
import { nextTick, ref } from 'vue';
import { Tree, useVbenModal } from '@vben/common-ui';
import { SystemMenuTypeEnum } from '@vben/constants';
import { handleTree } from '@vben/utils';
import { NCheckbox, NSpin } from 'naive-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { getSimpleMenusList } from '#/api/system/menu';
import { assignRoleMenu, getRoleMenuList } from '#/api/system/permission';
import { $t } from '#/locales';
import { useAssignMenuFormSchema } from '../data';
const emit = defineEmits(['success']);
const menuTree = ref<SystemMenuApi.Menu[]>([]); // 菜单树
const menuLoading = ref(false); // 加载菜单列表
const isAllSelected = ref(false); // 全选状态
const isExpanded = ref(false); // 展开状态
const expandedKeys = ref<number[]>([]); // 展开的节点
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useAssignMenuFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = await formApi.getValues();
try {
await assignRoleMenu({
roleId: data.id,
menuIds: data.menuIds,
});
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
// 加载菜单列表
await loadMenuTree();
const data = modalApi.getData<SystemRoleApi.Role>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
// 加载角色菜单
const menuIds = await getRoleMenuList(data.id);
await formApi.setFieldValue('menuIds', menuIds);
await formApi.setValues(data);
} finally {
await nextTick(); // 菜单过多,渲染较慢,需要等下一次事件循环
modalApi.unlock();
}
},
});
/** 加载菜单树 */
async function loadMenuTree() {
menuLoading.value = true;
try {
const data = await getSimpleMenusList();
menuTree.value = handleTree(data) as SystemMenuApi.Menu[];
} finally {
menuLoading.value = false;
}
}
/** 全选/全不选 */
function handleSelectAll() {
isAllSelected.value = !isAllSelected.value;
if (isAllSelected.value) {
const allIds = getAllNodeIds(menuTree.value);
formApi.setFieldValue('menuIds', allIds);
} else {
formApi.setFieldValue('menuIds', []);
}
}
/** 展开/折叠所有节点 */
function handleExpandAll() {
isExpanded.value = !isExpanded.value;
expandedKeys.value = isExpanded.value ? getAllNodeIds(menuTree.value) : [];
}
/** 递归获取所有节点 ID */
function getAllNodeIds(nodes: any[], ids: number[] = []): number[] {
nodes.forEach((node: any) => {
ids.push(node.id);
if (node.children && node.children.length > 0) {
getAllNodeIds(node.children, ids);
}
});
return ids;
}
function getNodeClass(node: Recordable<any>) {
const classes: string[] = [];
if (node.value?.type === SystemMenuTypeEnum.BUTTON) {
classes.push('inline-flex');
if (node.index % 3 >= 1) {
classes.push('!pl-0');
}
}
return classes.join(' ');
}
</script>
<template>
<Modal title="菜单权限" class="w-2/5">
<Form class="mx-4">
<template #menuIds="slotProps">
<NSpin :show="menuLoading" class="w-full">
<Tree
:tree-data="menuTree"
multiple
bordered
:default-expanded-keys="expandedKeys"
:get-node-class="getNodeClass"
v-bind="slotProps"
value-field="id"
label-field="name"
/>
</NSpin>
</template>
</Form>
<template #prepend-footer>
<div class="flex flex-auto items-center">
<NCheckbox :checked="isAllSelected" @change="handleSelectAll">
全选
</NCheckbox>
<NCheckbox :checked="isExpanded" @change="handleExpandAll">
全部展开
</NCheckbox>
</div>
</template>
</Modal>
</template>

View File

@@ -0,0 +1,81 @@
<script lang="ts" setup>
import type { SystemRoleApi } from '#/api/system/role';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { createRole, getRole, updateRole } from '#/api/system/role';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemRoleApi.Role>();
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 SystemRoleApi.Role;
try {
await (formData.value?.id ? updateRole(data) : createRole(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<SystemRoleApi.Role>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getRole(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,196 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'signature',
label: '短信签名',
component: 'Input',
componentProps: {
placeholder: '请输入短信签名',
},
rules: 'required',
},
{
fieldName: 'code',
label: '渠道编码',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE, 'string'),
placeholder: '请选择短信渠道',
},
rules: 'required',
},
{
fieldName: 'status',
label: '启用状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'remark',
label: '备注',
component: 'Input',
componentProps: {
type: 'textarea',
placeholder: '请输入备注',
},
},
{
fieldName: 'apiKey',
label: '短信 API 的账号',
component: 'Input',
componentProps: {
placeholder: '请输入短信 API 的账号',
},
rules: 'required',
},
{
fieldName: 'apiSecret',
label: '短信 API 的密钥',
component: 'Input',
componentProps: {
placeholder: '请输入短信 API 的密钥',
},
},
{
fieldName: 'callbackUrl',
label: '短信发送回调 URL',
component: 'Input',
componentProps: {
placeholder: '请输入短信发送回调 URL',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'signature',
label: '短信签名',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入短信签名',
},
},
{
fieldName: 'code',
label: '渠道编码',
component: 'Select',
componentProps: {
clearable: true,
options: getDictOptions(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE, 'string'),
placeholder: '请选择短信渠道',
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
clearable: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'DatePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '编号',
minWidth: 100,
},
{
field: 'signature',
title: '短信签名',
minWidth: 120,
},
{
field: 'code',
title: '渠道编码',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE },
},
},
{
field: 'status',
title: '启用状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'apiKey',
title: '短信 API 的账号',
minWidth: 180,
},
{
field: 'apiSecret',
title: '短信 API 的密钥',
minWidth: 180,
},
{
field: 'callbackUrl',
title: '短信发送回调 URL',
minWidth: 180,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'remark',
title: '备注',
minWidth: 120,
},
{
title: '操作',
width: 220,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,173 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemSmsChannelApi } from '#/api/system/sms/channel';
import { ref } from 'vue';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteSmsChannel,
deleteSmsChannelList,
getSmsChannelPage,
} from '#/api/system/sms/channel';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建短信渠道 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑短信渠道 */
function handleEdit(row: SystemSmsChannelApi.SmsChannel) {
formModalApi.setData(row).open();
}
/** 删除短信渠道 */
async function handleDelete(row: SystemSmsChannelApi.SmsChannel) {
const hideLoading = message.loading(
$t('ui.actionMessage.deleting', [row.signature]),
{ duration: 0 },
);
try {
await deleteSmsChannel(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.signature]));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 批量删除短信渠道 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading($t('ui.actionMessage.deletingBatch'), {
duration: 0,
});
try {
await deleteSmsChannelList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemSmsChannelApi.SmsChannel[];
}) {
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 getSmsChannelPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemSmsChannelApi.SmsChannel>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="短信配置" url="https://doc.iocoder.cn/sms/" />
</template>
<FormModal @success="handleRefresh" />
<Grid table-title="短信渠道列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['短信渠道']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:sms-channel:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'error',
icon: ACTION_ICON.DELETE,
disabled: isEmpty(checkedIds),
auth: ['system:sms-channel:delete'],
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
text: true,
icon: ACTION_ICON.EDIT,
auth: ['system:sms-channel:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'error',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:sms-channel:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.signature]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,87 @@
<script lang="ts" setup>
import type { SystemSmsChannelApi } from '#/api/system/sms/channel';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import {
createSmsChannel,
getSmsChannel,
updateSmsChannel,
} from '#/api/system/sms/channel';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemSmsChannelApi.SmsChannel>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['短信渠道'])
: $t('ui.actionTitle.create', ['短信渠道']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as SystemSmsChannelApi.SmsChannel;
try {
await (formData.value?.id
? updateSmsChannel(data)
: createSmsChannel(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<SystemSmsChannelApi.SmsChannel>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getSmsChannel(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,278 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemSmsLogApi } from '#/api/system/sms/log';
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { formatDateTime } from '@vben/utils';
import { getSimpleSmsChannelList } from '#/api/system/sms/channel';
import { DictTag } from '#/components/dict-tag';
import { getRangePickerDefaultProps } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'mobile',
label: '手机号',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入手机号',
},
},
{
fieldName: 'channelId',
label: '短信渠道',
component: 'ApiSelect',
componentProps: {
api: async () => await getSimpleSmsChannelList(),
labelField: 'signature',
valueField: 'id',
clearable: true,
placeholder: '请选择短信渠道',
},
},
{
fieldName: 'templateId',
label: '模板编号',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入模板编号',
},
},
{
fieldName: 'sendStatus',
label: '发送状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_SMS_SEND_STATUS, 'number'),
clearable: true,
placeholder: '请选择发送状态',
},
},
{
fieldName: 'sendTime',
label: '发送时间',
component: 'DatePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
{
fieldName: 'receiveStatus',
label: '接收状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_SMS_RECEIVE_STATUS, 'number'),
clearable: true,
placeholder: '请选择接收状态',
},
},
{
fieldName: 'receiveTime',
label: '接收时间',
component: 'DatePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 100,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'mobile',
title: '手机号',
minWidth: 120,
},
{
field: 'templateContent',
title: '短信内容',
minWidth: 300,
},
{
field: 'sendStatus',
title: '发送状态',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_SMS_SEND_STATUS },
},
},
{
field: 'sendTime',
title: '发送时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'receiveStatus',
title: '接收状态',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_SMS_RECEIVE_STATUS },
},
},
{
field: 'receiveTime',
title: '接收时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'channelCode',
title: '短信渠道',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE },
},
},
{
field: 'templateId',
title: '模板编号',
minWidth: 100,
},
{
field: 'templateType',
title: '短信类型',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 80,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 详情页的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'createTime',
label: '创建时间',
content: (data: SystemSmsLogApi.SmsLog) => {
return formatDateTime(data?.createTime || '') as string;
},
},
{
field: 'mobile',
label: '手机号',
},
{
field: 'channelCode',
label: '短信渠道',
},
{
field: 'templateId',
label: '模板编号',
},
{
field: 'templateType',
label: '模板类型',
content: (data: SystemSmsLogApi.SmsLog) => {
return h(DictTag, {
type: DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE,
value: data?.templateType,
});
},
},
{
field: 'templateContent',
label: '短信内容',
},
{
field: 'sendStatus',
label: '发送状态',
content: (data: SystemSmsLogApi.SmsLog) => {
return h(DictTag, {
type: DICT_TYPE.SYSTEM_SMS_SEND_STATUS,
value: data?.sendStatus,
});
},
},
{
field: 'sendTime',
label: '发送时间',
content: (data: SystemSmsLogApi.SmsLog) => {
return formatDateTime(data?.sendTime || '') as string;
},
},
{
field: 'apiSendCode',
label: 'API 发送编码',
},
{
field: 'apiSendMsg',
label: 'API 发送消息',
},
{
field: 'receiveStatus',
label: '接收状态',
content: (data: SystemSmsLogApi.SmsLog) => {
return h(DictTag, {
type: DICT_TYPE.SYSTEM_SMS_RECEIVE_STATUS,
value: data?.receiveStatus,
});
},
},
{
field: 'receiveTime',
label: '接收时间',
content: (data: SystemSmsLogApi.SmsLog) => {
return formatDateTime(data?.receiveTime || '') as string;
},
},
{
field: 'apiReceiveCode',
label: 'API 接收编码',
},
{
field: 'apiReceiveMsg',
label: 'API 接收消息',
span: 2,
},
{
field: 'apiRequestId',
label: 'API 请求 ID',
},
{
field: 'apiSerialNo',
label: 'API 序列号',
},
];
}

View File

@@ -0,0 +1,104 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemSmsLogApi } from '#/api/system/sms/log';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { exportSmsLog, getSmsLogPage } from '#/api/system/sms/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 exportSmsLog(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '短信日志.xls', source: data });
}
/** 查看短信日志详情 */
function handleDetail(row: SystemSmsLogApi.SmsLog) {
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 getSmsLogPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemSmsLogApi.SmsLog>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="短信配置" url="https://doc.iocoder.cn/sms/" />
</template>
<DetailModal @success="handleRefresh" />
<Grid table-title="短信日志列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['system:sms-log:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.detail'),
type: 'primary',
text: true,
icon: ACTION_ICON.VIEW,
auth: ['system:sms-log:query'],
onClick: handleDetail.bind(null, row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,53 @@
<script lang="ts" setup>
import type { SystemSmsLogApi } from '#/api/system/sms/log';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useDescription } from '#/components/description';
import { useDetailSchema } from '../data';
const formData = ref<SystemSmsLogApi.SmsLog>();
const [Descriptions] = useDescription({
componentProps: {
bordered: true,
column: 2,
contentClass: 'mx-4',
},
schema: useDetailSchema(),
});
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<SystemSmsLogApi.SmsLog>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = data;
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="短信日志详情"
class="w-[1280px]"
:show-cancel-button="false"
:show-confirm-button="false"
>
<Descriptions :data="formData" />
</Modal>
</template>

View File

@@ -0,0 +1,277 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getSimpleSmsChannelList } from '#/api/system/sms/channel';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'type',
label: '短信类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE, 'number'),
placeholder: '请选择短信类型',
},
rules: 'required',
},
{
fieldName: 'name',
label: '模板名称',
component: 'Input',
componentProps: {
placeholder: '请输入模板名称',
},
rules: 'required',
},
{
fieldName: 'code',
label: '模板编码',
component: 'Input',
componentProps: {
placeholder: '请输入模板编码',
},
rules: 'required',
},
{
fieldName: 'channelId',
label: '短信渠道',
component: 'ApiSelect',
componentProps: {
api: async () => await getSimpleSmsChannelList(),
labelField: 'signature',
valueField: 'id',
placeholder: '请选择短信渠道',
},
rules: 'required',
},
{
fieldName: 'status',
label: '开启状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'content',
label: '模板内容',
component: 'Input',
componentProps: {
type: 'textarea',
placeholder: '请输入模板内容',
rows: 4,
},
rules: 'required',
},
{
fieldName: 'apiTemplateId',
label: '短信 API 的模板编号',
component: 'Input',
componentProps: {
placeholder: '请输入短信 API 的模板编号',
},
rules: 'required',
},
{
fieldName: 'remark',
label: '备注',
component: 'Input',
componentProps: {
type: 'textarea',
placeholder: '请输入备注',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'type',
label: '短信类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE, 'number'),
clearable: true,
placeholder: '请选择短信类型',
},
},
{
fieldName: 'status',
label: '开启状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
clearable: true,
placeholder: '请选择开启状态',
},
},
{
fieldName: 'code',
label: '模板编码',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入模板编码',
},
},
{
fieldName: 'name',
label: '模板名称',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入模板名称',
},
},
{
fieldName: 'channelId',
label: '短信渠道',
component: 'ApiSelect',
componentProps: {
api: async () => await getSimpleSmsChannelList(),
labelField: 'signature',
valueField: 'id',
clearable: true,
placeholder: '请选择短信渠道',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'DatePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 发送短信表单 */
export function useSendSmsFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'content',
label: '模板内容',
component: 'Input',
componentProps: {
type: 'textarea',
disabled: true,
},
},
{
fieldName: 'mobile',
label: '手机号码',
component: 'Input',
componentProps: {
placeholder: '请输入手机号码',
},
rules: 'required',
},
{
fieldName: 'templateParams',
label: '模板参数',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '编号',
minWidth: 100,
},
{
field: 'type',
title: '短信类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE },
},
},
{
field: 'name',
title: '模板名称',
minWidth: 120,
},
{
field: 'code',
title: '模板编码',
minWidth: 120,
},
{
field: 'content',
title: '模板内容',
minWidth: 200,
},
{
field: 'status',
title: '开启状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'apiTemplateId',
title: '短信 API 的模板编号',
minWidth: 180,
},
{
field: 'channelCode',
title: '短信渠道',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'remark',
title: '备注',
minWidth: 120,
},
{
title: '操作',
width: 220,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,209 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemSmsTemplateApi } from '#/api/system/sms/template';
import { ref } from 'vue';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteSmsTemplate,
deleteSmsTemplateList,
exportSmsTemplate,
getSmsTemplatePage,
} from '#/api/system/sms/template';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import SendForm from './modules/send-form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [SendModal, sendModalApi] = useVbenModal({
connectedComponent: SendForm,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportSmsTemplate(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '短信模板.xls', source: data });
}
/** 创建短信模板 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑短信模板 */
function handleEdit(row: SystemSmsTemplateApi.SmsTemplate) {
formModalApi.setData(row).open();
}
/** 发送测试短信 */
function handleSend(row: SystemSmsTemplateApi.SmsTemplate) {
sendModalApi.setData(row).open();
}
/** 删除短信模板 */
async function handleDelete(row: SystemSmsTemplateApi.SmsTemplate) {
const hideLoading = message.loading(
$t('ui.actionMessage.deleting', [row.name]),
{
duration: 0,
},
);
try {
await deleteSmsTemplate(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 批量删除短信模板 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading($t('ui.actionMessage.deletingBatch'), {
duration: 0,
});
try {
await deleteSmsTemplateList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemSmsTemplateApi.SmsTemplate[];
}) {
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 getSmsTemplatePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemSmsTemplateApi.SmsTemplate>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="短信配置" url="https://doc.iocoder.cn/sms/" />
</template>
<FormModal @success="handleRefresh" />
<SendModal />
<Grid table-title="短信模板列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['短信模板']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:sms-template:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['system:sms-template:export'],
onClick: handleExport,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'error',
icon: ACTION_ICON.DELETE,
disabled: isEmpty(checkedIds),
auth: ['system:sms-template:delete'],
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
text: true,
icon: ACTION_ICON.EDIT,
auth: ['system:sms-template:update'],
onClick: handleEdit.bind(null, row),
},
{
label: '测试',
type: 'primary',
text: true,
icon: ACTION_ICON.VIEW,
auth: ['system:sms-template:send-sms'],
onClick: handleSend.bind(null, row),
},
{
label: $t('common.delete'),
type: 'error',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:sms-template: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 { SystemSmsTemplateApi } from '#/api/system/sms/template';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import {
createSmsTemplate,
getSmsTemplate,
updateSmsTemplate,
} from '#/api/system/sms/template';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemSmsTemplateApi.SmsTemplate>();
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 SystemSmsTemplateApi.SmsTemplate;
try {
await (formData.value?.id
? updateSmsTemplate(data)
: createSmsTemplate(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<SystemSmsTemplateApi.SmsTemplate>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getSmsTemplate(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,108 @@
<script lang="ts" setup>
import type { SystemSmsTemplateApi } from '#/api/system/sms/template';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { sendSms } from '#/api/system/sms/template';
import { useSendSmsFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemSmsTemplateApi.SmsTemplate>();
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 构建发送请求
const values = await formApi.getValues();
const paramsObj: Record<string, string> = {};
if (formData.value?.params) {
formData.value.params.forEach((param) => {
paramsObj[param] = values[`param_${param}`];
});
}
const data: SystemSmsTemplateApi.SmsSendReqVO = {
mobile: values.mobile,
templateCode: formData.value?.code || '',
templateParams: paramsObj,
};
// 提交表单
try {
await sendSms(data);
// 关闭并提示
await modalApi.close();
emit('success');
message.success('短信发送成功');
} catch (error) {
console.error('发送短信失败', error);
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 获取数据
const data = modalApi.getData<SystemSmsTemplateApi.SmsTemplate>();
if (!data) {
return;
}
formData.value = data;
// 更新 form schema
const schema = buildFormSchema();
formApi.setState({ schema });
// 设置到 values
await formApi.setValues({
content: data.content,
});
},
});
/** 动态构建表单 schema */
function buildFormSchema() {
const schema = useSendSmsFormSchema();
if (formData.value?.params) {
formData.value.params.forEach((param) => {
schema.push({
fieldName: `param_${param}`,
label: `参数 ${param}`,
component: 'Input',
componentProps: {
placeholder: `请输入参数 ${param}`,
},
rules: 'required',
});
});
}
return schema;
}
</script>
<template>
<Modal class="w-1/3" title="发送短信">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,211 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import {
CommonStatusEnum,
DICT_TYPE,
SystemUserSocialTypeEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '应用名',
component: 'Input',
componentProps: {
placeholder: '请输入应用名',
},
rules: 'required',
},
{
fieldName: 'socialType',
label: '社交平台',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_SOCIAL_TYPE, 'number'),
},
rules: 'required',
},
{
fieldName: 'userType',
label: '用户类型',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.USER_TYPE, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: 'required',
},
{
fieldName: 'clientId',
label: '客户端编号',
component: 'Input',
componentProps: {
placeholder: '请输入客户端编号,对应各平台的 appKey',
},
rules: 'required',
},
{
fieldName: 'clientSecret',
label: '客户端密钥',
component: 'Input',
componentProps: {
placeholder: '请输入客户端密钥,对应各平台的 appSecret',
},
rules: 'required',
},
{
fieldName: 'agentId',
label: 'agentId',
component: 'Input',
componentProps: {
placeholder: '授权方的网页应用 ID有则填',
},
dependencies: {
triggerFields: ['socialType'],
show: (values) =>
values.socialType === SystemUserSocialTypeEnum.WECHAT_ENTERPRISE.type,
},
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '应用名',
component: 'Input',
componentProps: {
placeholder: '请输入应用名',
clearable: true,
},
},
{
fieldName: 'socialType',
label: '社交平台',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_SOCIAL_TYPE, 'number'),
placeholder: '请选择社交平台',
clearable: true,
},
},
{
fieldName: 'userType',
label: '用户类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.USER_TYPE, 'number'),
placeholder: '请选择用户类型',
clearable: true,
},
},
{
fieldName: 'clientId',
label: '客户端编号',
component: 'Input',
componentProps: {
placeholder: '请输入客户端编号',
clearable: true,
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择状态',
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '编号',
minWidth: 100,
},
{
field: 'name',
title: '应用名',
minWidth: 120,
},
{
field: 'socialType',
title: '社交平台',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_SOCIAL_TYPE },
},
},
{
field: 'userType',
title: '用户类型',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.USER_TYPE },
},
},
{
field: 'clientId',
title: '客户端编号',
minWidth: 180,
},
{
field: 'status',
title: '状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 220,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,174 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemSocialClientApi } from '#/api/system/social/client';
import { ref } from 'vue';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteSocialClient,
deleteSocialClientList,
getSocialClientPage,
} from '#/api/system/social/client';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建社交客户端 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑社交客户端 */
function handleEdit(row: SystemSocialClientApi.SocialClient) {
formModalApi.setData(row).open();
}
/** 删除社交客户端 */
async function handleDelete(row: SystemSocialClientApi.SocialClient) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
});
try {
await deleteSocialClient(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 批量删除社交客户端 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading({
content: $t('ui.actionMessage.deletingBatch'),
duration: 0,
});
try {
await deleteSocialClientList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemSocialClientApi.SocialClient[];
}) {
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 getSocialClientPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemSocialClientApi.SocialClient>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="三方登录" url="https://doc.iocoder.cn/social-user/" />
</template>
<FormModal @success="handleRefresh" />
<Grid table-title="社交客户端列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['社交客户端']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:social-client:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'error',
icon: ACTION_ICON.DELETE,
auth: ['system:social-client:delete'],
disabled: isEmpty(checkedIds),
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
icon: ACTION_ICON.EDIT,
auth: ['system:social-client:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'error',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:social-client: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 { SystemSocialClientApi } from '#/api/system/social/client';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import {
createSocialClient,
getSocialClient,
updateSocialClient,
} from '#/api/system/social/client';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemSocialClientApi.SocialClient>();
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 SystemSocialClientApi.SocialClient;
try {
await (formData.value?.id
? updateSocialClient(data)
: createSocialClient(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<SystemSocialClientApi.SocialClient>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getSocialClient(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,164 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemSocialUserApi } from '#/api/system/social/user';
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { DictTag } from '#/components/dict-tag';
import { getRangePickerDefaultProps } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'type',
label: '社交平台',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_SOCIAL_TYPE, 'number'),
placeholder: '请选择社交平台',
clearable: true,
},
},
{
fieldName: 'nickname',
label: '用户昵称',
component: 'Input',
componentProps: {
placeholder: '请输入用户昵称',
clearable: true,
},
},
{
fieldName: 'openid',
label: '社交 openid',
component: 'Input',
componentProps: {
placeholder: '请输入社交 openid',
clearable: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'DatePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'type',
title: '社交平台',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_SOCIAL_TYPE },
},
},
{
field: 'openid',
title: '社交 openid',
minWidth: 180,
},
{
field: 'nickname',
title: '用户昵称',
minWidth: 120,
},
{
field: 'avatar',
title: '用户头像',
minWidth: 100,
cellRender: {
name: 'CellImage',
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'updateTime',
title: '更新时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 120,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 详情页的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'type',
label: '社交平台',
content: (data: SystemSocialUserApi.SocialUser) => {
return h(DictTag, {
type: DICT_TYPE.SYSTEM_SOCIAL_TYPE,
value: data?.type,
});
},
},
{
field: 'nickname',
label: '用户昵称',
},
{
field: 'avatar',
label: '用户头像',
// TODO @芋艿:使用 antd 的 Image 组件
content: (data: SystemSocialUserApi.SocialUser) => {
if (data?.avatar) {
return h('img', {
src: data.avatar,
style: 'width: 30px; height: 30px; cursor: pointer;',
onClick: () => {
// 可以添加图片预览功能
window.open(data.avatar, '_blank');
},
});
}
return '无';
},
},
{
field: 'token',
label: '社交 token',
},
{
field: 'rawTokenInfo',
label: '原始 Token 数据',
},
{
field: 'rawUserInfo',
label: '原始 User 数据',
},
{
field: 'code',
label: '最后一次的认证 code',
},
{
field: 'state',
label: '最后一次的认证 state',
},
];
}

View File

@@ -0,0 +1,78 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemSocialUserApi } from '#/api/system/social/user';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getSocialUserPage } from '#/api/system/social/user';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue';
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 查看详情 */
function handleDetail(row: SystemSocialUserApi.SocialUser) {
detailModalApi.setData(row).open();
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getSocialUserPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemSocialUserApi.SocialUser>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="三方登录" url="https://doc.iocoder.cn/social-user/" />
</template>
<DetailModal />
<Grid table-title="社交用户列表">
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.detail'),
type: 'primary',
icon: ACTION_ICON.VIEW,
auth: ['system:social-user:query'],
onClick: handleDetail.bind(null, row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import type { SystemSocialUserApi } from '#/api/system/social/user';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { getSocialUser } from '#/api/system/social/user';
import { useDescription } from '#/components/description';
import { $t } from '#/locales';
import { useDetailSchema } from '../data';
const formData = ref<SystemSocialUserApi.SocialUser>();
const [Descriptions] = useDescription({
componentProps: {
bordered: true,
column: 1,
size: 'middle',
class: 'mx-4',
labelStyle: { width: '185px' },
},
schema: useDetailSchema(),
});
const [Modal, modalApi] = useVbenModal({
title: $t('ui.actionTitle.detail'),
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getSocialUser(data.id);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="社交用户详情"
class="w-1/2"
:show-cancel-button="false"
:show-confirm-button="false"
>
<Descriptions :data="formData" />
</Modal>
</template>

View File

@@ -0,0 +1,247 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemTenantPackageApi } from '#/api/system/tenant-package';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getTenantPackageList } from '#/api/system/tenant-package';
import { getRangePickerDefaultProps } from '#/utils';
// TODO @xingyu这个不用 ref 么?
let tenantPackageList: SystemTenantPackageApi.TenantPackage[] = [];
async function getTenantPackageData() {
tenantPackageList = await getTenantPackageList();
}
getTenantPackageData();
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '租户名称',
component: 'Input',
rules: 'required',
},
{
fieldName: 'packageId',
label: '租户套餐',
component: 'ApiSelect',
componentProps: {
api: () => getTenantPackageList(),
labelField: 'name',
valueField: 'id',
placeholder: '请选择租户套餐',
},
rules: 'required',
},
{
fieldName: 'contactName',
label: '联系人',
component: 'Input',
rules: 'required',
},
{
fieldName: 'contactMobile',
label: '联系手机',
component: 'Input',
rules: 'mobile',
},
{
label: '用户名称',
fieldName: 'username',
component: 'Input',
rules: 'required',
dependencies: {
triggerFields: ['id'],
show: (values) => !values.id,
},
},
{
label: '用户密码',
fieldName: 'password',
component: 'InputPassword',
rules: 'required',
dependencies: {
triggerFields: ['id'],
show: (values) => !values.id,
},
},
{
label: '账号额度',
fieldName: 'accountCount',
component: 'InputNumber',
rules: 'required',
},
{
label: '过期时间',
fieldName: 'expireTime',
component: 'DatePicker',
componentProps: {
format: 'YYYY-MM-DD',
valueFormat: 'x',
placeholder: '请选择过期时间',
},
rules: 'required',
},
{
label: '绑定域名',
fieldName: 'websites',
component: 'Select',
componentProps: {
placeholder: '请输入绑定域名',
mode: 'tags',
clearable: true,
},
},
{
fieldName: 'status',
label: '租户状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '租户名',
component: 'Input',
componentProps: {
placeholder: '请输入租户名',
clearable: true,
},
},
{
fieldName: 'contactName',
label: '联系人',
component: 'Input',
componentProps: {
placeholder: '请输入联系人',
clearable: true,
},
},
{
fieldName: 'contactMobile',
label: '联系手机',
component: 'Input',
componentProps: {
placeholder: '请输入联系手机',
clearable: true,
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
placeholder: '请选择状态',
clearable: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'DatePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '租户编号',
minWidth: 100,
},
{
field: 'name',
title: '租户名',
minWidth: 180,
},
{
field: 'packageId',
title: '租户套餐',
minWidth: 180,
formatter: ({ cellValue }) => {
return cellValue === 0
? '系统租户'
: tenantPackageList.find((pkg) => pkg.id === cellValue)?.name || '-';
},
},
{
field: 'contactName',
title: '联系人',
minWidth: 100,
},
{
field: 'contactMobile',
title: '联系手机',
minWidth: 180,
},
{
field: 'accountCount',
title: '账号额度',
minWidth: 100,
},
{
field: 'expireTime',
title: '过期时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'websites',
title: '绑定域名',
minWidth: 180,
},
{
field: 'status',
title: '租户状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,185 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemTenantApi } from '#/api/system/tenant';
import { ref } from 'vue';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteTenant,
deleteTenantList,
exportTenant,
getTenantPage,
} from '#/api/system/tenant';
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 exportTenant(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '租户.xls', source: data });
}
/** 创建租户 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑租户 */
function handleEdit(row: SystemTenantApi.Tenant) {
formModalApi.setData(row).open();
}
/** 删除租户 */
async function handleDelete(row: SystemTenantApi.Tenant) {
const hideLoading = message.loading(
$t('ui.actionMessage.deleting', [row.name]),
{ duration: 0 },
);
try {
await deleteTenant(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 批量删除租户 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading($t('ui.actionMessage.deletingBatch'), {
duration: 0,
});
try {
await deleteTenantList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemTenantApi.Tenant[];
}) {
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 getTenantPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemTenantApi.Tenant>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="SaaS 多租户" url="https://doc.iocoder.cn/saas-tenant/" />
</template>
<FormModal @success="handleRefresh" />
<Grid table-title="租户列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['租户']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:tenant:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['system:tenant:export'],
onClick: handleExport,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'error',
icon: ACTION_ICON.DELETE,
disabled: isEmpty(checkedIds),
auth: ['system:tenant:delete'],
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
icon: ACTION_ICON.EDIT,
auth: ['system:tenant:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'error',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:tenant:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,80 @@
<script lang="ts" setup>
import type { SystemTenantApi } from '#/api/system/tenant';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from '#/adapter/naive';
import { useVbenForm } from '#/adapter/form';
import { createTenant, getTenant, updateTenant } from '#/api/system/tenant';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemTenantApi.Tenant>();
const getTitle = computed(() => {
return formData.value
? $t('ui.actionTitle.edit', ['租户'])
: $t('ui.actionTitle.create', ['租户']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
},
wrapperClass: 'grid-cols-1',
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 SystemTenantApi.Tenant;
try {
await (formData.value ? updateTenant(data) : createTenant(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<SystemTenantApi.Tenant>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getTenant(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,130 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '套餐名称',
component: 'Input',
rules: 'required',
},
{
fieldName: 'menuIds',
label: '菜单权限',
component: 'Input',
formItemClass: 'items-start',
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'remark',
label: '备注',
component: 'Input',
componentProps: {
type: 'textarea',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '套餐名称',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入套餐名称',
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
clearable: true,
placeholder: '请选择状态',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'DatePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '套餐编号',
minWidth: 100,
},
{
field: 'name',
title: '套餐名称',
minWidth: 180,
},
{
field: 'status',
title: '状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 220,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,172 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemTenantPackageApi } from '#/api/system/tenant-package';
import { ref } from 'vue';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { isEmpty } from '@vben/utils';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteTenantPackage,
deleteTenantPackageList,
getTenantPackagePage,
} from '#/api/system/tenant-package';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建租户套餐 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑租户套餐 */
function handleEdit(row: SystemTenantPackageApi.TenantPackage) {
formModalApi.setData(row).open();
}
/** 删除租户套餐 */
async function handleDelete(row: SystemTenantPackageApi.TenantPackage) {
const hideLoading = message.loading(
$t('ui.actionMessage.deleting', [row.name]),
{ duration: 0 },
);
try {
await deleteTenantPackage(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 批量删除租户套餐 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading($t('ui.actionMessage.deletingBatch'), {
duration: 0,
});
try {
await deleteTenantPackageList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemTenantPackageApi.TenantPackage[];
}) {
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 getTenantPackagePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemTenantPackageApi.TenantPackage>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="SaaS 多租户" url="https://doc.iocoder.cn/saas-tenant/" />
</template>
<FormModal @success="handleRefresh" />
<Grid table-title="租户套餐列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['套餐']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:tenant-package:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'error',
icon: ACTION_ICON.DELETE,
auth: ['system:tenant-package:delete'],
disabled: isEmpty(checkedIds),
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
icon: ACTION_ICON.EDIT,
auth: ['system:tenant-package:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'error',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:tenant-package:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,163 @@
<script lang="ts" setup>
import type { SystemMenuApi } from '#/api/system/menu';
import type { SystemTenantPackageApi } from '#/api/system/tenant-package';
import { computed, ref } from 'vue';
import { Tree, useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import { NCheckbox, NSpin } from 'naive-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { getMenuList } from '#/api/system/menu';
import {
createTenantPackage,
getTenantPackage,
updateTenantPackage,
} from '#/api/system/tenant-package';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemTenantPackageApi.TenantPackage>();
const getTitle = computed(() => {
return formData.value
? $t('ui.actionTitle.edit', ['套餐'])
: $t('ui.actionTitle.create', ['套餐']);
});
const menuTree = ref<SystemMenuApi.Menu[]>([]); // 菜单树
const menuLoading = ref(false); // 加载菜单列表
const isAllSelected = ref(false); // 全选状态
const isExpanded = ref(false); // 展开状态
const expandedKeys = ref<number[]>([]); // 展开的节点
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 SystemTenantPackageApi.TenantPackage;
try {
await (formData.value
? updateTenantPackage(data)
: createTenantPackage(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;
}
// 加载菜单列表
await loadMenuTree();
// 加载数据
const data = modalApi.getData<SystemTenantPackageApi.TenantPackage>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getTenantPackage(data.id);
await formApi.setValues(data);
} finally {
modalApi.unlock();
}
},
});
/** 加载菜单树 */
async function loadMenuTree() {
menuLoading.value = true;
try {
const data = await getMenuList();
menuTree.value = handleTree(data) as SystemMenuApi.Menu[];
} finally {
menuLoading.value = false;
}
}
/** 全选/全不选 */
function handleSelectAll() {
isAllSelected.value = !isAllSelected.value;
if (isAllSelected.value) {
const allIds = getAllNodeIds(menuTree.value);
formApi.setFieldValue('menuIds', allIds);
} else {
formApi.setFieldValue('menuIds', []);
}
}
/** 展开/折叠所有节点 */
function handleExpandAll() {
isExpanded.value = !isExpanded.value;
expandedKeys.value = isExpanded.value ? getAllNodeIds(menuTree.value) : [];
}
/** 递归获取所有节点 ID */
function getAllNodeIds(nodes: any[], ids: number[] = []): number[] {
nodes.forEach((node: any) => {
ids.push(node.id);
if (node.children && node.children.length > 0) {
getAllNodeIds(node.children, ids);
}
});
return ids;
}
</script>
<template>
<Modal :title="getTitle" class="w-2/5">
<Form class="mx-6">
<template #menuIds="slotProps">
<NSpin :show="menuLoading" content-class="w-full">
<Tree
class="max-h-96 overflow-y-auto"
:tree-data="menuTree"
multiple
bordered
:default-expanded-keys="expandedKeys"
v-bind="slotProps"
value-field="id"
label-field="name"
/>
</NSpin>
</template>
</Form>
<template #prepend-footer>
<div class="flex flex-auto items-center">
<NCheckbox :checked="isAllSelected" @change="handleSelectAll">
全选
</NCheckbox>
<NCheckbox :checked="isExpanded" @change="handleExpandAll">
全部展开
</NCheckbox>
</div>
</template>
</Modal>
</template>

View File

@@ -0,0 +1,347 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemUserApi } from '#/api/system/user';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { $t } from '@vben/locales';
import { handleTree } from '@vben/utils';
import { z } from '#/adapter/form';
import { getDeptList } from '#/api/system/dept';
import { getSimplePostList } from '#/api/system/post';
import { getSimpleRoleList } from '#/api/system/role';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'username',
label: '用户名称',
component: 'Input',
rules: 'required',
},
{
label: '用户密码',
fieldName: 'password',
component: 'InputPassword',
rules: 'required',
dependencies: {
triggerFields: ['id'],
show: (values) => !values.id,
},
},
{
fieldName: 'nickname',
label: '用户昵称',
component: 'Input',
rules: 'required',
},
{
fieldName: 'deptId',
label: '归属部门',
component: 'ApiTreeSelect',
componentProps: {
api: async () => {
const data = await getDeptList();
return handleTree(data);
},
labelField: 'name',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择归属部门',
treeDefaultExpandAll: true,
},
},
{
fieldName: 'postIds',
label: '岗位',
component: 'ApiSelect',
componentProps: {
api: getSimplePostList,
labelField: 'name',
valueField: 'id',
mode: 'multiple',
placeholder: '请选择岗位',
},
},
{
fieldName: 'email',
label: '邮箱',
component: 'Input',
rules: z.string().email('邮箱格式不正确').or(z.literal('')).optional(),
componentProps: {
placeholder: '请输入邮箱',
},
},
{
fieldName: 'mobile',
label: '手机号码',
component: 'Input',
componentProps: {
placeholder: '请输入手机号码',
},
},
{
fieldName: 'sex',
label: '用户性别',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(1),
},
{
fieldName: 'status',
label: '用户状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'remark',
label: '备注',
component: 'Input',
componentProps: {
type: 'textarea',
},
},
];
}
/** 重置密码的表单 */
export function useResetPasswordFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请输入新密码',
},
dependencies: {
rules(values) {
return z
.string({ message: '请输入新密码' })
.min(5, '密码长度不能少于 5 个字符')
.max(20, '密码长度不能超过 20 个字符')
.refine(
(value) => value !== values.oldPassword,
'新旧密码不能相同',
);
},
triggerFields: ['newPassword', 'oldPassword'],
},
fieldName: 'newPassword',
label: '新密码',
rules: 'required',
},
{
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: $t('authentication.confirmPassword'),
},
dependencies: {
rules(values) {
return z
.string({ message: '请输入确认密码' })
.min(5, '密码长度不能少于 5 个字符')
.max(20, '密码长度不能超过 20 个字符')
.refine(
(value) => value === values.newPassword,
'新密码和确认密码不一致',
);
},
triggerFields: ['newPassword', 'confirmPassword'],
},
fieldName: 'confirmPassword',
label: '确认密码',
rules: 'required',
},
];
}
/** 分配角色的表单 */
export function useAssignRoleFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'username',
label: '用户名称',
component: 'Input',
componentProps: {
disabled: true,
},
},
{
fieldName: 'nickname',
label: '用户昵称',
component: 'Input',
componentProps: {
disabled: true,
},
},
{
fieldName: 'roleIds',
label: '角色',
component: 'ApiSelect',
componentProps: {
api: getSimpleRoleList,
labelField: 'name',
valueField: 'id',
mode: 'multiple',
placeholder: '请选择角色',
},
},
];
}
/** 用户导入的表单 */
export function useImportFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'file',
label: '用户数据',
component: 'Upload',
rules: 'required',
help: '仅允许导入 xls、xlsx 格式文件',
},
{
fieldName: 'updateSupport',
label: '是否覆盖',
component: 'Switch',
componentProps: {
checkedChildren: '是',
unCheckedChildren: '否',
},
rules: z.boolean().default(false),
help: '是否更新已经存在的用户数据',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'username',
label: '用户名称',
component: 'Input',
componentProps: {
placeholder: '请输入用户名称',
clearable: true,
},
},
{
fieldName: 'mobile',
label: '手机号码',
component: 'Input',
componentProps: {
placeholder: '请输入手机号码',
clearable: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'DatePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(
onStatusChange?: (
newStatus: number,
row: SystemUserApi.User,
) => PromiseLike<boolean | undefined>,
): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '用户编号',
minWidth: 100,
},
{
field: 'username',
title: '用户名称',
minWidth: 120,
},
{
field: 'nickname',
title: '用户昵称',
minWidth: 120,
},
{
field: 'deptName',
title: '部门',
minWidth: 120,
},
{
field: 'mobile',
title: '手机号码',
minWidth: 120,
},
{
field: 'status',
title: '状态',
minWidth: 100,
align: 'center',
cellRender: {
attrs: { beforeChange: onStatusChange },
name: 'CellSwitch',
props: {
checkedValue: CommonStatusEnum.ENABLE,
unCheckedValue: CommonStatusEnum.DISABLE,
},
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 180,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,302 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemDeptApi } from '#/api/system/dept';
import type { SystemUserApi } from '#/api/system/user';
import { ref } from 'vue';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { NCard } from 'naive-ui';
import { message } from '#/adapter/naive';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteUser,
deleteUserList,
exportUser,
getUserPage,
updateUserStatus,
} from '#/api/system/user';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import AssignRoleForm from './modules/assign-role-form.vue';
import DeptTree from './modules/dept-tree.vue';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
import ResetPasswordForm from './modules/reset-password-form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [ResetPasswordModal, resetPasswordModalApi] = useVbenModal({
connectedComponent: ResetPasswordForm,
destroyOnClose: true,
});
const [AssignRoleModal, assignRoleModalApi] = useVbenModal({
connectedComponent: AssignRoleForm,
destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
connectedComponent: ImportForm,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportUser(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '用户.xls', source: data });
}
/** 选择部门 */
const searchDeptId = ref<number | undefined>(undefined);
async function handleDeptSelect(dept: SystemDeptApi.Dept) {
searchDeptId.value = dept.id;
handleRefresh();
}
/** 创建用户 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 导入用户 */
function handleImport() {
importModalApi.open();
}
/** 编辑用户 */
function handleEdit(row: SystemUserApi.User) {
formModalApi.setData(row).open();
}
/** 删除用户 */
async function handleDelete(row: SystemUserApi.User) {
const hideLoading = message.loading(
$t('ui.actionMessage.deleting', [row.username]),
{ duration: 0 },
);
try {
await deleteUser(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.username]));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
/** 批量删除用户 */
async function handleDeleteBatch() {
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
const hideLoading = message.loading($t('ui.actionMessage.deletingBatch'), {
duration: 0,
});
try {
await deleteUserList(checkedIds.value);
checkedIds.value = [];
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
hideLoading.destroy();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: SystemUserApi.User[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
/** 重置密码 */
function handleResetPassword(row: SystemUserApi.User) {
resetPasswordModalApi.setData(row).open();
}
/** 分配角色 */
function handleAssignRole(row: SystemUserApi.User) {
assignRoleModalApi.setData(row).open();
}
/** 更新用户状态 */
async function handleStatusChange(
newStatus: number,
row: SystemUserApi.User,
): Promise<boolean | undefined> {
return new Promise((resolve, reject) => {
confirm({
content: `你要将${row.username}的状态切换为【${getDictLabel(DICT_TYPE.COMMON_STATUS, newStatus)}】吗?`,
})
.then(async () => {
// 更新用户状态
const res = await updateUserStatus(row.id!, newStatus);
if (res) {
// 提示并返回成功
message.success($t('ui.actionMessage.operationSuccess'));
resolve(true);
} else {
reject(new Error('更新失败'));
}
})
.catch(() => {
reject(new Error('取消操作'));
});
});
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(handleStatusChange),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getUserPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
deptId: searchDeptId.value,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<SystemUserApi.User>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="用户体系" url="https://doc.iocoder.cn/user-center/" />
<DocAlert title="三方登陆" url="https://doc.iocoder.cn/social-user/" />
<DocAlert
title="Excel 导入导出"
url="https://doc.iocoder.cn/excel-import-and-export/"
/>
</template>
<FormModal @success="handleRefresh" />
<ResetPasswordModal @success="handleRefresh" />
<AssignRoleModal @success="handleRefresh" />
<ImportModal @success="handleRefresh" />
<div class="flex h-full w-full">
<!-- 左侧部门树 -->
<NCard class="mr-4 h-full w-1/6">
<DeptTree @select="handleDeptSelect" />
</NCard>
<!-- 右侧用户列表 -->
<div class="w-5/6">
<Grid table-title="用户列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['用户']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:user:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['system:user:export'],
onClick: handleExport,
},
{
label: $t('ui.actionTitle.import', ['用户']),
type: 'primary',
icon: ACTION_ICON.UPLOAD,
auth: ['system:user:import'],
onClick: handleImport,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'error',
icon: ACTION_ICON.DELETE,
disabled: isEmpty(checkedIds),
auth: ['system:user:delete'],
onClick: handleDeleteBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
text: true,
icon: ACTION_ICON.EDIT,
auth: ['system:user:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'error',
text: true,
icon: ACTION_ICON.DELETE,
auth: ['system:user:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.username]),
confirm: handleDelete.bind(null, row),
},
},
]"
:drop-down-actions="[
{
label: '分配角色',
type: 'primary',
text: true,
auth: ['system:permission:assign-user-role'],
onClick: handleAssignRole.bind(null, row),
},
{
label: '重置密码',
type: 'primary',
text: true,
auth: ['system:user:update-password'],
onClick: handleResetPassword.bind(null, row),
},
]"
/>
</template>
</Grid>
</div>
</div>
</Page>
</template>

View File

@@ -0,0 +1,77 @@
<script lang="ts" setup>
import type { SystemUserApi } from '#/api/system/user';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { assignUserRole, getUserRoleList } from '#/api/system/permission';
import { $t } from '#/locales';
import { useAssignRoleFormSchema } from '../data';
const emit = defineEmits(['success']);
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useAssignRoleFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const values = await formApi.getValues();
try {
await assignUserRole({
userId: values.id,
roleIds: values.roleIds,
});
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
// 加载数据
const data = modalApi.getData<SystemUserApi.User>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
const roleIds = await getUserRoleList(data.id);
// 设置到 values
await formApi.setValues({
...data,
roleIds,
});
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal title="分配角色">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,90 @@
<script lang="ts" setup>
import type { TreeOption, TreeOverrideNodeClickBehaviorReturn } from 'naive-ui';
import type { SystemDeptApi } from '#/api/system/dept';
import { onMounted, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { handleTree } from '@vben/utils';
import { NInput, NSpin, NTree } from 'naive-ui';
import { getSimpleDeptList } from '#/api/system/dept';
const emit = defineEmits(['select']);
const deptList = ref<SystemDeptApi.Dept[]>([]); // 部门列表
const deptTree = ref<any[]>([]); // 部门树
const expandedKeys = ref<number[]>([]); // 展开的节点
const loading = ref(false); // 加载状态
const searchValue = ref(''); // 搜索值
/** 处理搜索逻辑 */
function handleSearch(e: any) {
const value = e.target.value;
searchValue.value = value;
const filteredList = value
? deptList.value.filter((item) =>
item.name.toLowerCase().includes(value.toLowerCase()),
)
: deptList.value;
deptTree.value = handleTree(filteredList);
// 展开所有节点
expandedKeys.value = deptTree.value.map((node) => node.id!);
}
/** 选中部门 */
function handleSelect({
option,
}: {
option: TreeOption;
}): TreeOverrideNodeClickBehaviorReturn {
emit('select', option);
return 'default';
}
/** 初始化 */
onMounted(async () => {
try {
loading.value = true;
const data = await getSimpleDeptList();
deptList.value = data;
deptTree.value = handleTree(data);
} catch (error) {
console.error('获取部门数据失败', error);
} finally {
loading.value = false;
}
});
</script>
<template>
<div>
<NInput
placeholder="搜索部门"
allow-clear
v-model:value="searchValue"
@change="handleSearch"
class="w-full"
>
<template #prefix>
<IconifyIcon icon="lucide:search" class="size-4" />
</template>
</NInput>
<NSpin :show="loading" class="w-full">
<NTree
v-if="deptTree.length > 0"
class="pt-2"
:data="deptTree"
:default-expand-all="true"
key-field="id"
label-field="name"
children-field="children"
:override-default-node-click-behavior="handleSelect"
/>
<div v-else-if="!loading" class="py-4 text-center text-gray-500">
暂无数据
</div>
</NSpin>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<script lang="ts" setup>
import type { SystemUserApi } from '#/api/system/user';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { createUser, getUser, updateUser } from '#/api/system/user';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemUserApi.User>();
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 SystemUserApi.User;
try {
await (formData.value?.id ? updateUser(data) : createUser(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<SystemUserApi.User>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getUser(data.id);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-1/3">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,84 @@
<script lang="ts" setup>
import type { UploadFileInfo } from 'naive-ui';
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { NButton, NUpload } from 'naive-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { importUser, importUserTemplate } from '#/api/system/user';
import { $t } from '#/locales';
import { useImportFormSchema } from '../data';
const emit = defineEmits(['success']);
const [Form, formApi] = useVbenForm({
commonConfig: {
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useImportFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = await formApi.getValues();
try {
await importUser(data.file, data.updateSupport);
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
});
/** 上传前 */
function beforeUpload(file: UploadFileInfo) {
formApi.setFieldValue('file', file);
return false;
}
/** 下载模版 */
async function handleDownload() {
const data = await importUserTemplate();
downloadFileFromBlobPart({ fileName: '用户导入模板.xls', source: data });
}
</script>
<template>
<Modal title="导入用户" class="w-1/3">
<Form class="mx-4">
<template #file>
<div class="w-full">
<NUpload
:show-file-list="false"
:max-count="1"
accept=".xls,.xlsx"
:before-upload="beforeUpload"
>
<NButton type="primary"> 选择 Excel 文件 </NButton>
</NUpload>
</div>
</template>
</Form>
<template #prepend-footer>
<div class="flex flex-auto items-center">
<NButton @click="handleDownload"> 下载导入模板 </NButton>
</div>
</template>
</Modal>
</template>

View File

@@ -0,0 +1,65 @@
<script lang="ts" setup>
import type { SystemUserApi } from '#/api/system/user';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { resetUserPassword } from '#/api/system/user';
import { $t } from '#/locales';
import { useResetPasswordFormSchema } from '../data';
const emit = defineEmits(['success']);
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useResetPasswordFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = await formApi.getValues();
try {
await resetUserPassword(data.id, data.newPassword);
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
// 加载数据
const data = modalApi.getData<SystemUserApi.User>();
if (!data || !data.id) {
return;
}
// 设置到 values
await formApi.setValues(data);
},
});
</script>
<template>
<Modal title="重置密码">
<Form class="mx-4" />
</Modal>
</template>