This commit is contained in:
dap
2024-10-07 10:31:46 +08:00
40 changed files with 1695 additions and 144 deletions

View File

@@ -26,6 +26,7 @@
"#/*": "./src/*"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@tinymce/tinymce-vue": "^6.0.1",
"@vben/access": "workspace:*",
"@vben/common-ui": "workspace:*",

View File

@@ -36,6 +36,7 @@ import {
import { isArray } from 'lodash-es';
import { Tinymce as RichTextarea } from '#/components/tinymce';
import { ImageUpload } from '#/components/upload';
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
export type FormComponentType =
@@ -44,6 +45,7 @@ export type FormComponentType =
| 'CheckboxGroup'
| 'DatePicker'
| 'Divider'
| 'ImageUpload'
| 'Input'
| 'InputNumber'
| 'InputPassword'
@@ -104,6 +106,7 @@ setupVbenForm<FormComponentType>({
TimePicker,
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
Upload,
ImageUpload,
},
config: {
// ant design vue组件库默认都是 v-model:value

View File

@@ -1,7 +1,13 @@
import { requestClient } from '#/api/request';
/**
* 通过单文件上传接口
* @param file 上传的文件
* @returns 上传结果
*/
export function uploadApi(file: Blob | File) {
return requestClient.upload('/resource/oss/upload', file);
console.log('uploadApi', file);
return requestClient.upload('/resource/oss/upload', { file });
}
/**
* 默认上传结果

View File

@@ -0,0 +1 @@
export { default as ImageUpload } from './src/image-upload.vue';

View File

@@ -0,0 +1,32 @@
export function checkFileType(file: File, accepts: string[]) {
let reg;
if (!accepts || accepts.length === 0) {
reg = /.(?:jpg|jpeg|png|gif|webp)$/i;
} else {
const newTypes = accepts.join('|');
reg = new RegExp(`${String.raw`\.(` + newTypes})$`, 'i');
}
return reg.test(file.name);
}
export function checkImgType(file: File) {
return isImgTypeByName(file.name);
}
export function isImgTypeByName(name: string) {
return /\.(?:jpg|jpeg|png|gif|webp)$/i.test(name);
}
export function getBase64WithFile(file: File) {
return new Promise<{
file: File;
result: string;
}>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () =>
resolve({ result: reader.result as string, file }),
);
reader.addEventListener('error', (error) => reject(error));
});
}

View File

@@ -0,0 +1,261 @@
<script lang="ts" setup>
import type { Recordable } from '@vben/types';
import type { UploadFile, UploadProps } from 'ant-design-vue';
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
import { ref, toRefs, watch } from 'vue';
import { $t } from '@vben/locales';
import { PlusOutlined } from '@ant-design/icons-vue';
import { message, Modal, Upload } from 'ant-design-vue';
import { isArray, isFunction, isObject, isString } from 'lodash-es';
import { uploadApi } from '#/api';
import { checkFileType } from './helper';
import { UploadResultStatus } from './typing';
import { useUploadType } from './use-upload';
defineOptions({ name: 'ImageUpload' });
const props = withDefaults(
defineProps<{
/**
* 建议使用拓展名(不带.)
* 或者文件头 image/png等(测试判断不准确) 不支持image/*类似的写法
* 需自行改造 ./helper/checkFileType方法
*/
accept?: string[];
api?: (...args: any[]) => Promise<any>;
disabled?: boolean;
filename?: null | string;
helpText?: string;
// eslint-disable-next-line no-use-before-define
listType?: ListType;
// 最大数量的文件Infinity不限制
maxNumber?: number;
// 文件最大多少MB
maxSize?: number;
multiple?: boolean;
name?: string;
// support xxx.xxx.xx
resultField?: string;
uploadParams?: Recordable<any>;
value?: string[];
}>(),
{
value: () => [],
disabled: false,
listType: 'picture-card',
helpText: '',
maxSize: 2,
maxNumber: 1,
accept: () => [],
multiple: false,
uploadParams: () => ({}),
api: uploadApi,
name: 'file',
filename: null,
resultField: '',
},
);
const emit = defineEmits(['change', 'update:value', 'delete']);
type ListType = 'picture' | 'picture-card' | 'text';
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
const isInnerOperate = ref<boolean>(false);
const { getStringAccept } = useUploadType({
acceptRef: accept,
helpTextRef: helpText,
maxNumberRef: maxNumber,
maxSizeRef: maxSize,
});
const previewOpen = ref<boolean>(false);
const previewImage = ref<string>('');
const previewTitle = ref<string>('');
const fileList = ref<UploadProps['fileList']>([]);
const isLtMsg = ref<boolean>(true);
const isActMsg = ref<boolean>(true);
const isFirstRender = ref<boolean>(true);
watch(
() => props.value,
(v) => {
if (isInnerOperate.value) {
isInnerOperate.value = false;
return;
}
let value: string[] = [];
if (v) {
if (isArray(v)) {
value = v;
} else {
value.push(v);
}
fileList.value = value.map((item, i) => {
if (item && isString(item)) {
return {
uid: `${-i}`,
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: 'done',
url: item,
};
} else if (item && isObject(item)) {
return item;
}
return null;
}) as UploadProps['fileList'];
}
emit('update:value', value);
if (!isFirstRender.value) {
emit('change', value);
isFirstRender.value = false;
}
},
{
immediate: true,
deep: true,
},
);
function getBase64<T extends ArrayBuffer | null | string>(file: File) {
return new Promise<T>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () => {
resolve(reader.result as T);
});
reader.addEventListener('error', (error) => reject(error));
});
}
const handlePreview = async (file: UploadFile) => {
if (!file.url && !file.preview) {
file.preview = await getBase64<string>(file.originFileObj!);
}
previewImage.value = file.url || file.preview || '';
previewOpen.value = true;
previewTitle.value =
file.name ||
previewImage.value.slice(
Math.max(0, previewImage.value.lastIndexOf('/') + 1),
);
};
const handleRemove = async (file: UploadFile) => {
if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
index !== -1 && fileList.value.splice(index, 1);
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
emit('delete', file);
}
};
const handleCancel = () => {
previewOpen.value = false;
previewTitle.value = '';
};
const beforeUpload = (file: File) => {
const { maxSize, accept } = props;
const isAct = checkFileType(file, accept);
if (!isAct) {
message.error($t('component.upload.acceptUpload', [accept]));
isActMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isActMsg.value = true), 1000);
}
const isLt = file.size / 1024 / 1024 > maxSize;
if (isLt) {
message.error($t('component.upload.maxSizeMultiple', [maxSize]));
isLtMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isLtMsg.value = true), 1000);
}
return (isAct && !isLt) || Upload.LIST_IGNORE;
};
async function customRequest(info: UploadRequestOption<any>) {
const { api } = props;
if (!api || !isFunction(api)) {
console.warn('upload api must exist and be a function');
return;
}
try {
const res = await api?.(info.file);
/**
* 由getValue处理 传对象过去
* 直接传string(id)会被转为Number
*/
info.onSuccess!(res);
// 获取
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
} catch (error: any) {
console.error(error);
info.onError!(error);
}
}
function getValue() {
const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.DONE)
.map((item: any) => {
if (item?.response && props?.resultField) {
return item?.response?.[props.resultField];
}
// 注意这里取的key为 url
return item?.response?.url;
});
return list;
}
</script>
<template>
<div>
<Upload
v-bind="$attrs"
v-model:file-list="fileList"
:accept="getStringAccept"
:before-upload="beforeUpload"
:custom-request="customRequest"
:disabled="disabled"
:list-type="listType"
:max-count="maxNumber"
:multiple="multiple"
@preview="handlePreview"
@remove="handleRemove"
>
<div v-if="fileList && fileList.length < maxNumber">
<PlusOutlined />
<div style="margin-top: 8px">{{ $t('component.upload.upload') }}</div>
</div>
</Upload>
<Modal
:footer="null"
:open="previewOpen"
:title="previewTitle"
@cancel="handleCancel"
>
<img :src="previewImage" alt="" style="width: 100%" />
</Modal>
</div>
</template>
<style lang="less">
.ant-upload-select-picture-card i {
color: #999;
font-size: 32px;
}
.ant-upload-select-picture-card .ant-upload-text {
margin-top: 8px;
color: #666;
}
</style>

View File

@@ -0,0 +1,37 @@
import type { Recordable } from '@vben/types';
export enum UploadResultStatus {
DONE = 'done',
ERROR = 'error',
SUCCESS = 'success',
UPLOADING = 'uploading',
}
export interface FileItem {
thumbUrl?: string;
name: string;
size: number | string;
type?: string;
percent: number;
file: File;
status?: UploadResultStatus;
response?: { fileName: string; ossId: string; url: string } | Recordable<any>;
uuid: string;
}
export interface Wrapper {
record: FileItem;
uidKey: string;
valueKey: string;
}
export interface BaseFileItem {
uid: number | string;
url: string;
name?: string;
}
export interface PreviewFileItem {
url: string;
name: string;
type: string;
}

View File

@@ -0,0 +1,60 @@
import { computed, unref } from 'vue';
import type { Ref } from 'vue';
import { $t } from '@vben/locales';
export function useUploadType({
acceptRef,
helpTextRef,
maxNumberRef,
maxSizeRef,
}: {
acceptRef: Ref<string[]>;
helpTextRef: Ref<string>;
maxNumberRef: Ref<number>;
maxSizeRef: Ref<number>;
}) {
// 文件类型限制
const getAccept = computed(() => {
const accept = unref(acceptRef);
if (accept && accept.length > 0) {
return accept;
}
return [];
});
const getStringAccept = computed(() => {
return unref(getAccept)
.map((item) => {
return item.indexOf('/') > 0 || item.startsWith('.')
? item
: `.${item}`;
})
.join(',');
});
// 支持jpg、jpeg、png格式不超过2M最多可选择10张图片
const getHelpText = computed(() => {
const helpText = unref(helpTextRef);
if (helpText) {
return helpText;
}
const helpTexts: string[] = [];
const accept = unref(acceptRef);
if (accept.length > 0) {
helpTexts.push($t('component.upload.accept', [accept.join(',')]));
}
const maxSize = unref(maxSizeRef);
if (maxSize) {
helpTexts.push($t('component.upload.maxSize', [maxSize]));
}
const maxNumber = unref(maxNumberRef);
if (maxNumber && maxNumber !== Infinity) {
helpTexts.push($t('component.upload.maxNumber', [maxNumber]));
}
return helpTexts.join('');
});
return { getAccept, getStringAccept, getHelpText };
}

View File

@@ -29,6 +29,35 @@
"notice": {
"title": "Notice",
"received": "You have received a new message"
},
"upload": {
"save": "Save",
"upload": "Upload",
"imgUpload": "ImageUpload",
"uploaded": "Uploaded",
"operating": "Operating",
"del": "Delete",
"download": "download",
"saveWarn": "Please wait for the file to upload and save!",
"saveError": "There is no file successfully uploaded and cannot be saved!",
"preview": "Preview",
"choose": "Select the file",
"accept": "Support {0} format",
"acceptUpload": "Only upload files in {0} format",
"maxSize": "A single file does not exceed {0}MB ",
"maxSizeMultiple": "Only upload files up to {0}MB!",
"maxNumber": "Only upload up to {0} files",
"legend": "Legend",
"fileName": "File name",
"fileSize": "File size",
"fileStatue": "File status",
"pending": "Pending",
"startUpload": "Start upload",
"uploadSuccess": "Upload successfully",
"uploadError": "Upload failed",
"uploading": "Uploading",
"uploadWait": "Please wait for the file upload to finish",
"reUploadFailed": "Re-upload failed files"
}
},
"pages": {

View File

@@ -29,6 +29,35 @@
"notice": {
"title": "消息",
"received": "收到新消息"
},
"upload": {
"save": "保存",
"upload": "上传",
"imgUpload": "图片上传",
"uploaded": "已上传",
"operating": "操作",
"del": "删除",
"download": "下载",
"saveWarn": "请等待文件上传后,保存!",
"saveError": "没有上传成功的文件,无法保存!",
"preview": "预览",
"choose": "选择文件",
"accept": "支持{0}格式",
"acceptUpload": "只能上传{0}格式文件",
"maxSize": "单个文件不超过{0}MB",
"maxSizeMultiple": "只能上传不超过{0}MB的文件!",
"maxNumber": "最多只能上传{0}个文件",
"legend": "略缩图",
"fileName": "文件名",
"fileSize": "文件大小",
"fileStatue": "状态",
"pending": "待上传",
"startUpload": "开始上传",
"uploadSuccess": "上传成功",
"uploadError": "上传失败",
"uploading": "上传中",
"uploadWait": "请等待文件上传结束后操作",
"reUploadFailed": "重新上传失败文件"
}
},
"pages": {

View File

@@ -3,7 +3,7 @@ import type { UserProfile } from '#/api/system/profile/model';
import { computed } from 'vue';
import { usePreferences } from '@vben/preferences';
import { preferences, usePreferences } from '@vben/preferences';
import {
Card,
@@ -24,9 +24,7 @@ defineEmits<{
}>();
const avatar = computed(
() =>
props.profile?.user.avatar ??
'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
() => props.profile?.user.avatar ?? preferences.app.defaultAvatar,
);
const { isDark } = usePreferences();

View File

@@ -0,0 +1,60 @@
import type { DemoForm, DemoQuery, DemoVO } from './model';
import type { ID, IDS, PageResult } from '#/api/common';
import { commonExport } from '#/api/helper';
import { requestClient } from '#/api/request';
/**
* 查询测试单表列表
* @param params
* @returns 测试单表列表
*/
export function demoList(params?: DemoQuery) {
return requestClient.get<PageResult<DemoVO>>('/demo/demo/list', { params });
}
/**
* 导出测试单表列表
* @param params
* @returns 测试单表列表
*/
export function demoExport(params?: DemoQuery) {
return commonExport('/demo/demo/export', params ?? {});
}
/**
* 查询测试单表详情
* @param id id
* @returns 测试单表详情
*/
export function demoInfo(id: ID) {
return requestClient.get<DemoVO>(`/demo/demo/${id}`);
}
/**
* 新增测试单表
* @param data
* @returns void
*/
export function demoAdd(data: DemoForm) {
return requestClient.postWithMsg<void>('/demo/demo', data);
}
/**
* 更新测试单表
* @param data
* @returns void
*/
export function demoUpdate(data: DemoForm) {
return requestClient.putWithMsg<void>('/demo/demo', data);
}
/**
* 删除测试单表
* @param id id
* @returns void
*/
export function demoRemove(id: ID | IDS) {
return requestClient.deleteWithMsg<void>(`/demo/demo/${id}`);
}

View File

@@ -0,0 +1,82 @@
import type { BaseEntity, PageQuery } from '#/api/common';
export interface DemoVO {
/**
* 主键
*/
id: number | string;
/**
* 排序号
*/
orderNum: number;
/**
* key键
*/
testKey: string;
/**
* 值
*/
value: string;
/**
* 版本
*/
version: number;
}
export interface DemoForm extends BaseEntity {
/**
* 主键
*/
id?: number | string;
/**
* 排序号
*/
orderNum?: number;
/**
* key键
*/
testKey?: string;
/**
* 值
*/
value?: string;
/**
* 版本
*/
version?: number;
}
export interface DemoQuery extends PageQuery {
/**
* 排序号
*/
orderNum?: number;
/**
* key键
*/
testKey?: string;
/**
* 值
*/
value?: string;
/**
* 版本
*/
version?: number;
/**
* 日期范围参数
*/
params?: any;
}

View File

@@ -0,0 +1,91 @@
import type { FormSchemaGetter, VxeGridProps } from '#/adapter';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'orderNum',
label: '排序号',
},
{
component: 'Input',
fieldName: 'testKey',
label: 'key键',
},
{
component: 'Input',
fieldName: 'value',
label: '值',
},
{
component: 'Input',
fieldName: 'version',
label: '版本',
},
];
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
{
title: '主键',
field: 'id',
},
{
title: '排序号',
field: 'orderNum',
},
{
title: 'key键',
field: 'testKey',
},
{
title: '值',
field: 'value',
},
{
title: '版本',
field: 'version',
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
width: 180,
},
];
export const modalSchema: FormSchemaGetter = () => [
{
label: '主键',
fieldName: 'id',
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
},
{
label: '排序号',
fieldName: 'orderNum',
component: 'Input',
rules: 'required',
},
{
label: 'key键',
fieldName: 'testKey',
component: 'Input',
rules: 'required',
},
{
label: '值',
fieldName: 'value',
component: 'Input',
rules: 'required',
},
{
label: '版本',
fieldName: 'version',
component: 'Input',
rules: 'required',
},
];

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { cloneDeep } from '@vben/utils';
import { useVbenForm } from '#/adapter';
import { demoAdd, demoInfo, demoUpdate } from './api';
import { modalSchema } from './data';
const emit = defineEmits<{ reload: [] }>();
const isUpdate = ref(false);
const title = computed(() => {
return isUpdate.value ? $t('pages.common.edit') : $t('pages.common.add');
});
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
// 默认占满两列
formItemClass: 'col-span-2',
// 默认label宽度 px
labelWidth: 80,
// 通用配置项 会影响到所有表单项
componentProps: {
class: 'w-full',
},
},
schema: modalSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
const [BasicModal, modalApi] = useVbenModal({
fullscreenButton: false,
onCancel: handleCancel,
onConfirm: handleConfirm,
onOpenChange: async (isOpen) => {
if (!isOpen) {
return null;
}
modalApi.modalLoading(true);
const { id } = modalApi.getData() as { id?: number | string };
isUpdate.value = !!id;
if (isUpdate.value && id) {
const record = await demoInfo(id);
await formApi.setValues(record);
}
modalApi.modalLoading(false);
},
});
async function handleConfirm() {
try {
modalApi.modalLoading(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
// getValues获取为一个readonly的对象 需要修改必须先深拷贝一次
const data = cloneDeep(await formApi.getValues());
await (isUpdate.value ? demoUpdate(data) : demoAdd(data));
emit('reload');
await handleCancel();
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
}
}
async function handleCancel() {
modalApi.close();
await formApi.resetForm();
}
</script>
<template>
<BasicModal :close-on-click-modal="false" :title="title" class="w-[550px]">
<BasicForm />
</BasicModal>
</template>

View File

@@ -1,9 +1,183 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
import type { Recordable } from '@vben/types';
import { ref } from 'vue';
import { Page, useVbenModal, type VbenFormProps } from '@vben/common-ui';
import { Modal, Popconfirm, Space } from 'ant-design-vue';
import dayjs from 'dayjs';
import { useVbenVxeGrid, type VxeGridProps } from '#/adapter';
import { downloadExcel } from '#/utils/file/download';
import { demoExport, demoList, demoRemove } from './api';
import { columns, querySchema } from './data';
import demoModal from './demo-modal.vue';
const formOptions: VbenFormProps = {
commonConfig: {
labelWidth: 80,
},
schema: querySchema(),
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
};
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
// 点击行选中
// trigger: 'row',
},
columns,
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
// 区间选择器处理
if (formValues?.createTime) {
formValues.params = {
beginTime: dayjs(formValues.createTime[0]).format(
'YYYY-MM-DD 00:00:00',
),
endTime: dayjs(formValues.createTime[1]).format(
'YYYY-MM-DD 23:59:59',
),
};
Reflect.deleteProperty(formValues, 'createTime');
} else {
Reflect.deleteProperty(formValues, 'params');
}
return await demoList({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
isHover: true,
keyField: 'id',
},
round: true,
align: 'center',
showOverflow: true,
};
const checked = ref(false);
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents: {
checkboxChange: (e: any) => {
checked.value = e.records.length > 0;
},
checkboxAll: (e: any) => {
checked.value = e.records.length > 0;
},
},
});
const [DemoModal, modalApi] = useVbenModal({
connectedComponent: demoModal,
});
function handleAdd() {
modalApi.setData({});
modalApi.open();
}
async function handleEdit(record: Recordable<any>) {
modalApi.setData({ id: record.id });
modalApi.open();
}
async function handleDelete(row: Recordable<any>) {
await demoRemove(row.id);
await tableApi.query();
}
function handleMultiDelete() {
const rows = tableApi.grid.getCheckboxRecords();
const ids = rows.map((row: any) => row.id);
Modal.confirm({
title: '提示',
okType: 'danger',
content: `确认删除选中的${ids.length}条记录吗?`,
onOk: async () => {
await demoRemove(ids);
await tableApi.query();
checked.value = false;
},
});
}
</script>
<template>
<div>
<CommonSkeleton />
</div>
<Page :auto-content-height="true">
<BasicTable>
<template #toolbar-actions>
<span class="pl-[7px] text-[16px]">测试单表列表</span>
</template>
<template #toolbar-tools>
<Space>
<a-button
v-access:code="['demo:demo:export']"
@click="downloadExcel(demoExport, '测试单表数据', {})"
>
{{ $t('pages.common.export') }}
</a-button>
<a-button
:disabled="!checked"
danger
type="primary"
v-access:code="['demo:demo:remove']"
@click="handleMultiDelete"
>
{{ $t('pages.common.delete') }}
</a-button>
<a-button
type="primary"
v-access:code="['demo:demo:add']"
@click="handleAdd"
>
{{ $t('pages.common.add') }}
</a-button>
</Space>
</template>
<template #action="{ row }">
<a-button
size="small"
type="link"
v-access:code="['demo:demo:edit']"
@click="handleEdit(row)"
>
{{ $t('pages.common.edit') }}
</a-button>
<Popconfirm
placement="left"
title="确认删除?"
@confirm="handleDelete(row)"
>
<a-button
danger
size="small"
type="link"
v-access:code="['demo:demo:remove']"
@click.stop=""
>
{{ $t('pages.common.delete') }}
</a-button>
</Popconfirm>
</template>
</BasicTable>
<DemoModal @reload="tableApi.query()" />
</Page>
</template>

View File

@@ -0,0 +1,50 @@
import type { TreeForm, TreeQuery, TreeVO } from './model';
import type { ID, IDS } from '#/api/common';
import { requestClient } from '#/api/request';
/**
* 查询测试树列表
* @param params
* @returns 测试树列表
*/
export function treeList(params?: TreeQuery) {
return requestClient.get<TreeVO[]>('/demo/tree/list', { params });
}
/**
* 查询测试树详情
* @param id id
* @returns 测试树详情
*/
export function treeInfo(id: ID) {
return requestClient.get<TreeVO>(`/demo/tree/${id}`);
}
/**
* 新增测试树
* @param data
* @returns void
*/
export function treeAdd(data: TreeForm) {
return requestClient.postWithMsg<void>('/demo/tree', data);
}
/**
* 更新测试树
* @param data
* @returns void
*/
export function treeUpdate(data: TreeForm) {
return requestClient.putWithMsg<void>('/demo/tree', data);
}
/**
* 删除测试树
* @param id id
* @returns void
*/
export function treeRemove(id: ID | IDS) {
return requestClient.deleteWithMsg<void>(`/demo/tree/${id}`);
}

View File

@@ -0,0 +1,102 @@
import type { BaseEntity } from '#/api/common';
export interface TreeVO {
/**
* 主键
*/
id: number | string;
/**
* 父id
*/
parentId: number | string;
/**
* 部门id
*/
deptId: number | string;
/**
* 用户id
*/
userId: number | string;
/**
* 值
*/
treeName: string;
/**
* 版本
*/
version: number;
/**
* 子对象
*/
children: TreeVO[];
}
export interface TreeForm extends BaseEntity {
/**
* 主键
*/
id?: number | string;
/**
* 父id
*/
parentId?: number | string;
/**
* 部门id
*/
deptId?: number | string;
/**
* 用户id
*/
userId?: number | string;
/**
* 值
*/
treeName?: string;
/**
* 版本
*/
version?: number;
}
export interface TreeQuery {
/**
* 父id
*/
parentId?: number | string;
/**
* 部门id
*/
deptId?: number | string;
/**
* 用户id
*/
userId?: number | string;
/**
* 值
*/
treeName?: string;
/**
* 版本
*/
version?: number;
/**
* 日期范围参数
*/
params?: any;
}

View File

@@ -0,0 +1,106 @@
import type { FormSchemaGetter, VxeGridProps } from '#/adapter';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'parentId',
label: '父id',
},
{
component: 'Input',
fieldName: 'deptId',
label: '部门id',
},
{
component: 'Input',
fieldName: 'userId',
label: '用户id',
},
{
component: 'Input',
fieldName: 'treeName',
label: '值',
},
{
component: 'Input',
fieldName: 'version',
label: '版本',
},
];
export const columns: VxeGridProps['columns'] = [
{
title: '主键',
field: 'id',
treeNode: true,
},
{
title: '父id',
field: 'parentId',
},
{
title: '部门id',
field: 'deptId',
},
{
title: '用户id',
field: 'userId',
},
{
title: '值',
field: 'treeName',
},
{
title: '版本',
field: 'version',
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
width: 180,
},
];
export const modalSchema: FormSchemaGetter = () => [
{
label: '主键',
fieldName: 'id',
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
},
{
label: '父id',
fieldName: 'parentId',
component: 'TreeSelect',
rules: 'required',
},
{
label: '部门id',
fieldName: 'deptId',
component: 'Input',
rules: 'required',
},
{
label: '用户id',
fieldName: 'userId',
component: 'Input',
rules: 'required',
},
{
label: '值',
fieldName: 'treeName',
component: 'Input',
rules: 'required',
},
{
label: '版本',
fieldName: 'version',
component: 'Input',
rules: 'required',
},
];

View File

@@ -1,9 +1,147 @@
<script setup lang="ts">
import CommonSkeleton from '#/views/common';
import type { Recordable } from '@vben/types';
import { nextTick } from 'vue';
import { Page, useVbenModal, type VbenFormProps } from '@vben/common-ui';
import { listToTree } from '@vben/utils';
import { Popconfirm, Space } from 'ant-design-vue';
import { useVbenVxeGrid, type VxeGridProps } from '#/adapter';
import { treeList, treeRemove } from './api';
import { columns, querySchema } from './data';
import treeModal from './tree-modal.vue';
const formOptions: VbenFormProps = {
commonConfig: {
labelWidth: 80,
},
schema: querySchema(),
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
};
const gridOptions: VxeGridProps = {
columns,
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_, formValues = {}) => {
const resp = await treeList({
...formValues,
});
const treeData = listToTree(resp, {
id: 'id',
pid: 'parentId',
children: 'children',
});
return { rows: treeData };
},
// 默认请求接口后展开全部 不需要可以删除这段
querySuccess: () => {
nextTick(() => {
expandAll();
});
},
},
},
rowConfig: {
isHover: true,
keyField: 'id',
},
round: true,
align: 'center',
showOverflow: true,
treeConfig: {
parentField: 'parentId',
rowField: 'id',
transform: false,
},
};
const [BasicTable, tableApi] = useVbenVxeGrid({ formOptions, gridOptions });
const [TreeModal, modalApi] = useVbenModal({
connectedComponent: treeModal,
});
function handleAdd() {
modalApi.setData({ update: false });
modalApi.open();
}
async function handleEdit(row: Recordable<any>) {
modalApi.setData({ id: row.id, update: true });
modalApi.open();
}
async function handleDelete(row: Recordable<any>) {
await treeRemove(row.id);
await tableApi.query();
}
function expandAll() {
tableApi.grid?.setAllTreeExpand(true);
}
function collapseAll() {
tableApi.grid?.setAllTreeExpand(false);
}
</script>
<template>
<div>
<CommonSkeleton />
</div>
<Page :auto-content-height="true">
<BasicTable>
<template #toolbar-actions>
<span class="pl-[7px] text-[16px]">测试树列表</span>
</template>
<template #toolbar-tools>
<Space>
<a-button @click="collapseAll">
{{ $t('pages.common.collapse') }}
</a-button>
<a-button @click="expandAll">
{{ $t('pages.common.expand') }}
</a-button>
<a-button
type="primary"
v-access:code="['demo:tree:add']"
@click="handleAdd"
>
{{ $t('pages.common.add') }}
</a-button>
</Space>
</template>
<template #action="{ row }">
<a-button
size="small"
type="link"
v-access:code="['demo:tree:edit']"
@click="handleEdit(row)"
>
{{ $t('pages.common.edit') }}
</a-button>
<Popconfirm
placement="left"
title="确认删除?"
@confirm="handleDelete(row)"
>
<a-button
danger
size="small"
type="link"
v-access:code="['demo:tree:remove']"
@click.stop=""
>
{{ $t('pages.common.delete') }}
</a-button>
</Popconfirm>
</template>
</BasicTable>
<TreeModal @reload="tableApi.query()" />
</Page>
</template>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { cloneDeep, listToTree } from '@vben/utils';
import { useVbenForm } from '#/adapter';
import { treeAdd, treeInfo, treeList, treeUpdate } from './api';
import { modalSchema } from './data';
const emit = defineEmits<{ reload: [] }>();
const isUpdate = ref(false);
const title = computed(() => {
return isUpdate.value ? $t('pages.common.edit') : $t('pages.common.add');
});
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
// 默认占满两列
formItemClass: 'col-span-2',
// 默认label宽度 px
labelWidth: 80,
// 通用配置项 会影响到所有表单项
componentProps: {
class: 'w-full',
},
},
schema: modalSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
async function setupTreeSelect() {
const listData = await treeList();
const treeData = listToTree(listData, { id: 'id', pid: 'parentId' });
formApi.updateSchema([
{
fieldName: 'parentId',
componentProps: {
treeData,
treeLine: { showLeafIcon: false },
fieldNames: { label: 'treeName', value: 'id' },
treeDefaultExpandAll: true,
},
},
]);
}
const [BasicModal, modalApi] = useVbenModal({
fullscreenButton: false,
onCancel: handleCancel,
onConfirm: handleConfirm,
onOpenChange: async (isOpen) => {
if (!isOpen) {
return null;
}
modalApi.modalLoading(true);
const { id } = modalApi.getData() as { id?: number | string };
isUpdate.value = !!id;
if (isUpdate.value && id) {
const record = await treeInfo(id);
await formApi.setValues(record);
}
await setupTreeSelect();
modalApi.modalLoading(false);
},
});
async function handleConfirm() {
try {
modalApi.modalLoading(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
// getValues获取为一个readonly的对象 需要修改必须先深拷贝一次
const data = cloneDeep(await formApi.getValues());
await (isUpdate.value ? treeUpdate(data) : treeAdd(data));
emit('reload');
await handleCancel();
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
}
}
async function handleCancel() {
modalApi.close();
await formApi.resetForm();
}
</script>
<template>
<BasicModal :close-on-click-modal="false" :title="title" class="w-[550px]">
<BasicForm />
</BasicModal>
</template>

View File

@@ -137,7 +137,10 @@ async function handleUnlock() {
</template>
<template #toolbar-tools>
<Space>
<a-button @click="handleClear">
<a-button
v-access:code="['monitor:logininfor:remove']"
@click="handleClear"
>
{{ $t('pages.common.clear') }}
</a-button>
<a-button
@@ -155,7 +158,12 @@ async function handleUnlock() {
>
{{ $t('pages.common.delete') }}
</a-button>
<a-button :disabled="!canUnlock" type="primary" @click="handleUnlock">
<a-button
:disabled="!canUnlock"
type="primary"
v-access:code="['monitor:logininfor:unlock']"
@click="handleUnlock"
>
解锁
</a-button>
</Space>

View File

@@ -1,7 +1,11 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import { computed } from 'vue';
import { useAccess } from '@vben/access';
import { Page, useVbenDrawer, type VbenFormProps } from '@vben/common-ui';
import { Fallback } from '@vben/common-ui';
import { listToTree } from '@vben/utils';
import { Popconfirm, Space } from 'ant-design-vue';
@@ -83,10 +87,23 @@ function expandAll() {
function collapseAll() {
tableApi.grid?.setAllTreeExpand(false);
}
/**
* 与后台逻辑相同
* 只有租户管理和超级管理能访问菜单管理
*/
const { hasAccessByRoles } = useAccess();
const isAdmin = computed(() => {
return hasAccessByRoles(['admin', 'superadmin']);
});
/**
* 不要问为什么有两个根节点 v-if会控制只会渲染一个
*/
</script>
<template>
<Page :auto-content-height="true">
<Page v-if="isAdmin" :auto-content-height="true">
<BasicTable>
<template #toolbar-actions>
<span class="pl-[7px] text-[16px]">菜单权限列表</span>
@@ -136,4 +153,5 @@ function collapseAll() {
</BasicTable>
<MenuDrawer @reload="tableApi.query()" />
</Page>
<Fallback v-else description="您没有菜单管理的访问权限" status="403" />
</template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAccess } from '@vben/access';
@@ -141,7 +141,9 @@ function handleMultiDelete() {
});
}
const { hasAccessByCodes } = useAccess();
const { hasAccessByCodes, hasAccessByRoles } = useAccess();
const isSuperAdmin = computed(() => hasAccessByRoles(['superadmin']));
const [RoleAuthModal, authModalApi] = useVbenModal({
connectedComponent: roleAuthModal,
@@ -203,7 +205,11 @@ function handleAssignRole(record: Recordable<any>) {
/>
</template>
<template #action="{ row }">
<template v-if="row.roleId !== 1">
<!-- 租户管理员不可修改admin角色 防止误操作 -->
<!-- 超级管理员可通过租户切换来操作租户管理员角色 -->
<template
v-if="!row.superAdmin && (row.roleKey !== 'admin' || isSuperAdmin)"
>
<a-button
size="small"
type="link"

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page, useVbenDrawer, type VbenFormProps } from '@vben/common-ui';
import { Fallback } from '@vben/common-ui';
import { Modal, Popconfirm, Space } from 'ant-design-vue';
import dayjs from 'dayjs';
@@ -15,6 +16,7 @@ import {
tenantList,
tenantRemove,
tenantStatusChange,
tenantSyncPackage,
} from '#/api/system/tenant';
import { TableSwitch } from '#/components/table';
import { useTenantStore } from '#/store/tenant';
@@ -107,6 +109,12 @@ async function handleEdit(record: Recordable<any>) {
drawerApi.open();
}
async function handleSync(record: Recordable<any>) {
const { tenantId, packageId } = record;
await tenantSyncPackage(tenantId, packageId);
await tableApi.query();
}
const tenantStore = useTenantStore();
async function handleDelete(row: Recordable<any>) {
await tenantRemove(row.id);
@@ -131,11 +139,19 @@ function handleMultiDelete() {
},
});
}
const { hasAccessByCodes } = useAccess();
/**
* 与后台逻辑相同
* 只有超级管理员能访问租户相关
*/
const { hasAccessByCodes, hasAccessByRoles } = useAccess();
const isSuperAdmin = computed(() => {
return hasAccessByRoles(['superadmin']);
});
</script>
<template>
<Page :auto-content-height="true">
<Page v-if="isSuperAdmin" :auto-content-height="true">
<BasicTable>
<template #toolbar-actions>
<span class="pl-[7px] text-[16px]">租户列表 </span>
@@ -183,6 +199,19 @@ const { hasAccessByCodes } = useAccess();
>
{{ $t('pages.common.edit') }}
</a-button>
<Popconfirm
:title="`确认同步[${row.companyName}]的套餐吗?`"
placement="left"
@confirm="handleSync(row)"
>
<a-button
size="small"
type="link"
v-access:code="['system:tenant:edit']"
>
{{ $t('pages.common.sync') }}
</a-button>
</Popconfirm>
<Popconfirm
placement="left"
title="确认删除?"
@@ -202,4 +231,5 @@ const { hasAccessByCodes } = useAccess();
</BasicTable>
<TenantDrawer @reload="tableApi.query()" />
</Page>
<Fallback v-else description="您没有租户的访问权限" status="403" />
</template>

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page, useVbenDrawer, type VbenFormProps } from '@vben/common-ui';
import { Fallback } from '@vben/common-ui';
import { Modal, Popconfirm, Space } from 'ant-design-vue';
import dayjs from 'dayjs';
@@ -126,11 +127,19 @@ function handleMultiDelete() {
});
}
const { hasAccessByCodes } = useAccess();
/**
* 与后台逻辑相同
* 只有超级管理员能访问租户相关
*/
const { hasAccessByCodes, hasAccessByRoles } = useAccess();
const isSuperAdmin = computed(() => {
return hasAccessByRoles(['superadmin']);
});
</script>
<template>
<Page :auto-content-height="true">
<Page v-if="isSuperAdmin" :auto-content-height="true">
<BasicTable>
<template #toolbar-actions>
<span class="pl-[7px] text-[16px]">租户套餐列表</span>
@@ -197,4 +206,5 @@ const { hasAccessByCodes } = useAccess();
</BasicTable>
<TenantPackageDrawer @reload="tableApi.query()" />
</Page>
<Fallback v-else description="您没有租户的访问权限" status="403" />
</template>

View File

@@ -10,6 +10,7 @@ import {
type VbenFormProps,
} from '@vben/common-ui';
import { $t } from '@vben/locales';
import { preferences } from '@vben/preferences';
import { getPopupContainer } from '@vben/utils';
import {
@@ -122,7 +123,6 @@ const gridOptions: VxeGridProps = {
align: 'center',
showOverflow: true,
};
const checked = ref(false);
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
@@ -236,10 +236,7 @@ function handleResetPwd(record: Recordable<any>) {
</template>
<template #avatar="{ row }">
<Avatar v-if="row.avatar" :src="row.avatar" />
<Avatar
v-else
src="https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png"
/>
<Avatar v-else :src="preferences.app.defaultAvatar" />
</template>
<template #status="{ row }">
<TableSwitch

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { ref } from 'vue';
import { JsonPreview, Page } from '@vben/common-ui';
import { RadioGroup } from 'ant-design-vue';
import { ImageUpload } from '#/components/upload';
const resultField = ref<'ossId' | 'url'>('ossId');
const fileList = ref([]);
const fieldOptions = [
{ label: 'ossId', value: 'ossId' },
{ label: '链接地址', value: 'url' },
];
</script>
<template>
<Page class="flex flex-col gap-[8px]">
<div class="bg-background flex flex-col gap-[12px] rounded-lg p-6">
<div class="flex gap-[8px]">
<span>返回字段: </span>
<RadioGroup v-model:value="resultField" :options="fieldOptions" />
</div>
<ImageUpload v-model:value="fileList" :result-field="resultField" />
<JsonPreview :data="fileList" />
</div>
</Page>
</template>