refactor: 升级框架

This commit is contained in:
caiyuchao
2025-07-09 11:28:52 +08:00
parent bbe6d7e76e
commit 258c0e2934
310 changed files with 11060 additions and 8152 deletions

View File

@@ -29,14 +29,14 @@ withDefaults(
<Card :body-style="bodyStyle" :title="title" class="mb-4">
<template v-if="title" #title>
<div class="flex items-center">
<span class="text-4 font-[700]">{{ title }}</span>
<span class="text-base font-bold">{{ title }}</span>
<Tooltip placement="right">
<template #title>
<div class="max-w-[200px]">{{ message }}</div>
</template>
<ShieldQuestion :size="14" class="ml-5px" />
<ShieldQuestion :size="14" class="ml-1" />
</Tooltip>
<div class="pl-20px flex flex-grow">
<div class="flex flex-grow pl-5">
<slot name="header"></slot>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import type { CropperAvatarProps } from './typing';
import { computed, ref, unref, watch, watchEffect } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { Button, message } from 'ant-design-vue';
@@ -27,13 +28,10 @@ const props = withDefaults(defineProps<CropperAvatarProps>(), {
const emit = defineEmits(['update:value', 'change']);
const sourceValue = ref(props.value || '');
const prefixCls = 'cropper-avatar';
const [CropperModal, modalApi] = useVbenModal({
connectedComponent: cropperModal,
});
const getClass = computed(() => [prefixCls]);
const getWidth = computed(() => `${`${props.width}`.replace(/px/, '')}px`);
const getIconWidth = computed(
@@ -73,28 +71,42 @@ defineExpose({
</script>
<template>
<div :class="getClass" :style="getStyle">
<!-- 头像容器 -->
<div class="inline-block text-center" :style="getStyle">
<!-- 图片包装器 -->
<div
:class="`${prefixCls}-image-wrapper`"
class="bg-card group relative cursor-pointer overflow-hidden rounded-full border border-gray-200"
:style="getImageWrapperStyle"
@click="openModal"
>
<div :class="`${prefixCls}-image-mask`" :style="getImageWrapperStyle">
<span
<!-- 遮罩层 -->
<div
class="duration-400 absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black bg-opacity-40 opacity-0 transition-opacity group-hover:opacity-100"
:style="getImageWrapperStyle"
>
<IconifyIcon
icon="lucide:cloud-upload"
class="m-auto text-gray-400"
:style="{
...getImageWrapperStyle,
width: `${getIconWidth}`,
height: `${getIconWidth}`,
lineHeight: `${getIconWidth}`,
width: getIconWidth,
height: getIconWidth,
lineHeight: getIconWidth,
}"
class="icon-[ant-design--cloud-upload-outlined] text-[#d6d6d6]"
></span>
/>
</div>
<img v-if="sourceValue" :src="sourceValue" alt="avatar" />
<!-- 头像图片 -->
<img
v-if="sourceValue"
:src="sourceValue"
alt="avatar"
class="h-full w-full object-cover"
/>
</div>
<!-- 上传按钮 -->
<Button
v-if="showBtn"
:class="`${prefixCls}-upload-btn`"
class="mx-auto mt-2"
@click="openModal"
v-bind="btnProps"
>
@@ -109,49 +121,3 @@ defineExpose({
/>
</div>
</template>
<style lang="scss" scoped>
.cropper-avatar {
display: inline-block;
text-align: center;
&-image-wrapper {
overflow: hidden;
cursor: pointer;
background: #fff;
border: 1px solid #eee;
border-radius: 50%;
img {
width: 100%;
}
}
&-image-mask {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: inherit;
height: inherit;
cursor: pointer;
background: rgb(0 0 0 / 40%);
border: inherit;
border-radius: inherit;
opacity: 0;
transition: opacity 0.4s;
::v-deep(svg) {
margin: auto;
}
}
&-image-mask:hover {
opacity: 40;
}
&-upload-btn {
margin: 10px auto;
}
}
</style>

View File

@@ -4,6 +4,7 @@ import type { CropendResult, CropperModalProps, CropperType } from './typing';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { dataURLtoBlob, isFunction } from '@vben/utils';
@@ -36,13 +37,20 @@ const cropper = ref<CropperType>();
let scaleX = 1;
let scaleY = 1;
const prefixCls = 'cropper-am';
const [Modal, modalApi] = useVbenModal({
onConfirm: handleOk,
onOpenChange(isOpen) {
if (isOpen) {
// 打开时,进行 loading 加载。后续 CropperImage 组件加载完毕,会自动关闭 loading通过 handleReady
modalLoading(true);
const img = new Image();
img.src = src.value;
img.addEventListener('load', () => {
modalLoading(false);
});
img.addEventListener('error', () => {
modalLoading(false);
});
} else {
// 关闭时,清空右侧预览
previewSource.value = '';
@@ -118,11 +126,15 @@ async function handleOk() {
:confirm-text="$t('ui.cropper.okText')"
:fullscreen-button="false"
:title="$t('ui.cropper.modalTitle')"
class="w-[800px]"
class="w-2/3"
>
<div :class="prefixCls">
<div :class="`${prefixCls}-left`" class="w-full">
<div :class="`${prefixCls}-cropper`">
<div class="flex h-96">
<!-- 左侧区域 -->
<div class="h-full w-3/5">
<!-- 裁剪器容器 -->
<div
class="relative h-[300px] bg-gradient-to-b from-neutral-50 to-neutral-200"
>
<CropperImage
v-if="src"
:circled="circled"
@@ -133,7 +145,8 @@ async function handleOk() {
/>
</div>
<div :class="`${prefixCls}-toolbar`">
<!-- 工具栏 -->
<div class="mt-4 flex items-center justify-between">
<Upload
:before-upload="handleBeforeUpload"
:file-list="[]"
@@ -143,7 +156,7 @@ async function handleOk() {
<Button size="small" type="primary">
<template #icon>
<div class="flex items-center justify-center">
<span class="icon-[ant-design--upload-outlined]"></span>
<IconifyIcon icon="lucide:upload" />
</div>
</template>
</Button>
@@ -159,7 +172,7 @@ async function handleOk() {
>
<template #icon>
<div class="flex items-center justify-center">
<span class="icon-[ant-design--reload-outlined]"></span>
<IconifyIcon icon="lucide:rotate-ccw" />
</div>
</template>
</Button>
@@ -176,9 +189,7 @@ async function handleOk() {
>
<template #icon>
<div class="flex items-center justify-center">
<span
class="icon-[ant-design--rotate-left-outlined]"
></span>
<IconifyIcon icon="ant-design:rotate-left-outlined" />
</div>
</template>
</Button>
@@ -189,16 +200,13 @@ async function handleOk() {
>
<Button
:disabled="!src"
pre-icon="ant-design:rotate-right-outlined"
size="small"
type="primary"
@click="handlerToolbar('rotate', 45)"
>
<template #icon>
<div class="flex items-center justify-center">
<span
class="icon-[ant-design--rotate-right-outlined]"
></span>
<IconifyIcon icon="ant-design:rotate-right-outlined" />
</div>
</template>
</Button>
@@ -212,7 +220,7 @@ async function handleOk() {
>
<template #icon>
<div class="flex items-center justify-center">
<span class="icon-[vaadin--arrows-long-h]"></span>
<IconifyIcon icon="vaadin:arrows-long-h" />
</div>
</template>
</Button>
@@ -226,7 +234,7 @@ async function handleOk() {
>
<template #icon>
<div class="flex items-center justify-center">
<span class="icon-[vaadin--arrows-long-v]"></span>
<IconifyIcon icon="vaadin:arrows-long-v" />
</div>
</template>
</Button>
@@ -240,7 +248,7 @@ async function handleOk() {
>
<template #icon>
<div class="flex items-center justify-center">
<span class="icon-[ant-design--zoom-in-outlined]"></span>
<IconifyIcon icon="lucide:zoom-in" />
</div>
</template>
</Button>
@@ -254,7 +262,7 @@ async function handleOk() {
>
<template #icon>
<div class="flex items-center justify-center">
<span class="icon-[ant-design--zoom-out-outlined]"></span>
<IconifyIcon icon="lucide:zoom-out" />
</div>
</template>
</Button>
@@ -262,16 +270,26 @@ async function handleOk() {
</Space>
</div>
</div>
<div :class="`${prefixCls}-right`">
<div :class="`${prefixCls}-preview`">
<!-- 右侧区域 -->
<div class="h-full w-2/5">
<!-- 预览区域 -->
<div
class="mx-auto h-56 w-56 overflow-hidden rounded-full border border-gray-200"
>
<img
v-if="previewSource"
:alt="$t('ui.cropper.preview')"
:src="previewSource"
class="h-full w-full object-cover"
/>
</div>
<!-- 头像组合预览 -->
<template v-if="previewSource">
<div :class="`${prefixCls}-group`">
<div
class="mt-2 flex items-center justify-around border-t border-gray-200 pt-2"
>
<Avatar :src="previewSource" size="large" />
<Avatar :size="48" :src="previewSource" />
<Avatar :size="64" :src="previewSource" />
@@ -282,76 +300,3 @@ async function handleOk() {
</div>
</Modal>
</template>
<style lang="scss">
.cropper-am {
display: flex;
&-left,
&-right {
height: 340px;
}
&-left {
width: 55%;
}
&-right {
width: 45%;
}
&-cropper {
height: 300px;
background: #eee;
background-image:
linear-gradient(
45deg,
rgb(0 0 0 / 25%) 25%,
transparent 0,
transparent 75%,
rgb(0 0 0 / 25%) 0
),
linear-gradient(
45deg,
rgb(0 0 0 / 25%) 25%,
transparent 0,
transparent 75%,
rgb(0 0 0 / 25%) 0
);
background-position:
0 0,
12px 12px;
background-size: 24px 24px;
}
&-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
}
&-preview {
width: 220px;
height: 220px;
margin: 0 auto;
overflow: hidden;
border: 1px solid #eee;
border-radius: 50%;
img {
width: 100%;
height: 100%;
}
}
&-group {
display: flex;
align-items: center;
justify-content: space-around;
padding-top: 8px;
margin-top: 8px;
border-top: 1px solid #eee;
}
}
</style>

View File

@@ -33,7 +33,6 @@ const imgElRef = ref<ElRef<HTMLImageElement>>();
const cropper = ref<Cropper | null>();
const isReady = ref(false);
const prefixCls = 'cropper-image';
const debounceRealTimeCropped = useDebounceFn(realTimeCropped, 80);
const getImageStyle = computed((): CSSProperties => {
@@ -46,10 +45,9 @@ const getImageStyle = computed((): CSSProperties => {
const getClass = computed(() => {
return [
prefixCls,
attrs.class,
{
[`${prefixCls}--circled`]: props.circled,
'cropper-image--circled': props.circled,
},
];
});
@@ -115,10 +113,9 @@ function cropped() {
imgInfo,
});
};
// eslint-disable-next-line unicorn/prefer-add-event-listener
fileReader.onerror = () => {
fileReader.addEventListener('error', () => {
emit('cropendError');
};
});
}, 'image/png');
}
@@ -157,6 +154,7 @@ function getRoundedCanvas() {
:crossorigin="crossorigin"
:src="src"
:style="getImageStyle"
class="h-auto max-w-full"
/>
</div>
</template>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed } from 'vue';
import { isValidColor, TinyColor } from '@vben/utils';
import { Tag } from 'ant-design-vue';
// import { isHexColor } from '@/utils/color' // TODO @芋艿:【可优化】增加 cssClass 的处理 https://gitee.com/yudaocode/yudao-ui-admin-vben/blob/v2.4.1/src/components/DictTag/src/DictTag.vue#L60
import { getDictObj } from '#/utils';
interface DictTagProps {
@@ -58,15 +59,23 @@ const dictTag = computed(() => {
}
}
if (isValidColor(dict.cssClass)) {
colorType = new TinyColor(dict.cssClass).toHexString();
}
return {
label: dict.label || '',
colorType,
cssClass: dict.cssClass,
};
});
</script>
<template>
<Tag v-if="dictTag" :color="dictTag.colorType">
<Tag
v-if="dictTag"
:color="dictTag.colorType ? dictTag.colorType : dictTag.cssClass"
>
{{ dictTag.label }}
</Tag>
</template>

View File

@@ -13,7 +13,7 @@ import {
SelectOption,
} from 'ant-design-vue';
import { getDictObj, getIntDictOptions, getStrDictOptions } from '#/utils';
import { getDictOptions } from '#/utils';
defineOptions({ name: 'DictSelect' });
@@ -25,17 +25,16 @@ const props = withDefaults(defineProps<DictSelectProps>(), {
const attrs = useAttrs();
// 获得字典配置
// TODO @dhb可以使用 getDictOptions 替代么?
const getDictOptions = computed(() => {
const getDictOption = computed(() => {
switch (props.valueType) {
case 'bool': {
return getDictObj(props.dictType, 'bool');
return getDictOptions(props.dictType, 'boolean');
}
case 'int': {
return getIntDictOptions(props.dictType);
return getDictOptions(props.dictType, 'number');
}
case 'str': {
return getStrDictOptions(props.dictType);
return getDictOptions(props.dictType, 'string');
}
default: {
return [];
@@ -45,27 +44,27 @@ const getDictOptions = computed(() => {
</script>
<template>
<Select v-if="selectType === 'select'" class="w-1/1" v-bind="attrs">
<Select v-if="selectType === 'select'" class="w-full" v-bind="attrs">
<SelectOption
v-for="(dict, index) in getDictOptions"
v-for="(dict, index) in getDictOption"
:key="index"
:value="dict.value"
>
{{ dict.label }}
</SelectOption>
</Select>
<RadioGroup v-if="selectType === 'radio'" class="w-1/1" v-bind="attrs">
<RadioGroup v-if="selectType === 'radio'" class="w-full" v-bind="attrs">
<Radio
v-for="(dict, index) in getDictOptions"
v-for="(dict, index) in getDictOption"
:key="index"
:value="dict.value"
>
{{ dict.label }}
</Radio>
</RadioGroup>
<CheckboxGroup v-if="selectType === 'checkbox'" class="w-1/1" v-bind="attrs">
<CheckboxGroup v-if="selectType === 'checkbox'" class="w-full" v-bind="attrs">
<Checkbox
v-for="(dict, index) in getDictOptions"
v-for="(dict, index) in getDictOption"
:key="index"
:value="dict.value"
>

View File

@@ -190,7 +190,7 @@ export const useApiSelect = (option: ApiSelectProps) => {
// fix多写此步是为了解决 multiple 属性问题
return (
<Select
class="w-1/1"
class="w-full"
loading={loading.value}
mode="multiple"
{...attrs}
@@ -210,7 +210,7 @@ export const useApiSelect = (option: ApiSelectProps) => {
}
return (
<Select
class="w-1/1"
class="w-full"
loading={loading.value}
{...attrs}
// TODO: @dhb52 remote 对等实现, 还是说没作用
@@ -235,7 +235,7 @@ export const useApiSelect = (option: ApiSelectProps) => {
];
}
return (
<CheckboxGroup class="w-1/1" {...attrs}>
<CheckboxGroup class="w-full" {...attrs}>
{options.value.map(
(item: { label: any; value: any }, index: any) => (
<Checkbox key={index} value={item.value}>
@@ -254,7 +254,7 @@ export const useApiSelect = (option: ApiSelectProps) => {
];
}
return (
<RadioGroup class="w-1/1" {...attrs}>
<RadioGroup class="w-full" {...attrs}>
{options.value.map(
(item: { label: any; value: any }, index: any) => (
<Radio key={index} value={item.value}>

View File

@@ -121,7 +121,7 @@ const apiSelectRule = [
field: 'data',
title: '请求参数 JSON 格式',
props: {
autosize: true,
autoSize: true,
type: 'textarea',
placeholder: '{"type": 1}',
},
@@ -155,7 +155,7 @@ const apiSelectRule = [
info: `data 为接口返回值,需要写一个匿名函数解析返回值为选择器 options 列表
(data: any)=>{ label: string; value: any }[]`,
props: {
autosize: true,
autoSize: true,
rows: { minRows: 2, maxRows: 6 },
type: 'textarea',
placeholder: `

View File

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

View File

@@ -1,12 +1,10 @@
<!-- add by 星语参考 vben2 的方式增加 TableAction 组件 -->
<script setup lang="ts">
import type { ButtonType } from 'ant-design-vue/es/button';
import type { PropType } from 'vue';
import type { ActionItem, PopConfirm } from './typing';
import { computed, toRaw } from 'vue';
import { computed, unref } from 'vue';
import { useAccess } from '@vben/access';
import { IconifyIcon } from '@vben/icons';
@@ -43,34 +41,31 @@ const props = defineProps({
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(() => {
return (toRaw(props.actions) || [])
.filter((action) => {
return (
(hasAccessByCodes(action.auth || []) ||
(action.auth || []).length === 0) &&
isIfShow(action)
);
})
.map((action) => {
return (props.actions || [])
.filter((action: ActionItem) => isIfShow(action))
.map((action: ActionItem) => {
const { popConfirm } = action;
return {
// getPopupContainer: document.body,
type: 'link' as ButtonType,
type: action.type || 'link',
...action,
...popConfirm,
onConfirm: popConfirm?.confirm,
@@ -80,19 +75,16 @@ const getActions = computed(() => {
});
});
const getDropdownList = computed((): any[] => {
return (toRaw(props.dropDownActions) || [])
.filter((action) => {
return (
(hasAccessByCodes(action.auth || []) ||
(action.auth || []).length === 0) &&
isIfShow(action)
);
})
.map((action, index) => {
/** 处理下拉菜单 actions */
const getDropdownList = computed(() => {
return (props.dropDownActions || [])
.filter((action: ActionItem) => isIfShow(action))
.map((action: ActionItem, index: number) => {
const { label, popConfirm } = action;
const processedAction = { ...action };
delete processedAction.icon;
return {
...action,
...processedAction,
...popConfirm,
onConfirm: popConfirm?.confirm,
onCancel: popConfirm?.cancel,
@@ -103,8 +95,16 @@ const getDropdownList = computed((): any[] => {
});
});
/** Space 组件的 size */
const spaceSize = computed(() => {
return unref(getActions)?.some((item: ActionItem) => item.type === 'link')
? 0
: 8;
});
/** 获取 PopConfirm 属性 */
function getPopConfirmProps(attrs: PopConfirm) {
const originAttrs: any = attrs;
const originAttrs: any = { ...attrs };
delete originAttrs.icon;
if (attrs.confirm && isFunction(attrs.confirm)) {
originAttrs.onConfirm = attrs.confirm;
@@ -117,31 +117,44 @@ function getPopConfirmProps(attrs: PopConfirm) {
return originAttrs;
}
/** 获取 Button 属性 */
function getButtonProps(action: ActionItem) {
const res = {
type: action.type || 'primary',
...action,
return {
type: action.type || 'link',
danger: action.danger || false,
disabled: action.disabled,
loading: action.loading,
size: action.size,
};
delete res.icon;
return res;
}
/** 获取 Tooltip 属性 */
function getTooltipProps(tooltip: any | string) {
if (!tooltip) return {};
return typeof tooltip === 'string' ? { title: tooltip } : { ...tooltip };
}
/** 处理菜单点击 */
function handleMenuClick(e: any) {
const action = getDropdownList.value[e.key];
if (action.onClick && isFunction(action.onClick)) {
if (action && action.onClick && isFunction(action.onClick)) {
action.onClick();
}
}
/** 生成稳定的 key */
function getActionKey(action: ActionItem, index: number) {
return `${action.label || ''}-${action.type || ''}-${index}`;
}
</script>
<template>
<div class="m-table-action">
<Space
:size="
getActions?.some((item: ActionItem) => item.type === 'link') ? 0 : 8
"
>
<template v-for="(action, index) in getActions" :key="index">
<div class="table-actions">
<Space :size="spaceSize">
<template
v-for="(action, index) in getActions"
:key="getActionKey(action, index)"
>
<Popconfirm
v-if="action.popConfirm"
v-bind="getPopConfirmProps(action.popConfirm)"
@@ -149,13 +162,7 @@ function handleMenuClick(e: any) {
<template v-if="action.popConfirm.icon" #icon>
<IconifyIcon :icon="action.popConfirm.icon" />
</template>
<Tooltip
v-bind="
typeof action.tooltip === 'string'
? { title: action.tooltip }
: { ...action.tooltip }
"
>
<Tooltip v-bind="getTooltipProps(action.tooltip)">
<Button v-bind="getButtonProps(action)">
<template v-if="action.icon" #icon>
<IconifyIcon :icon="action.icon" />
@@ -164,14 +171,7 @@ function handleMenuClick(e: any) {
</Button>
</Tooltip>
</Popconfirm>
<Tooltip
v-else
v-bind="
typeof action.tooltip === 'string'
? { title: action.tooltip }
: { ...action.tooltip }
"
>
<Tooltip v-else v-bind="getTooltipProps(action.tooltip)">
<Button v-bind="getButtonProps(action)" @click="action.onClick">
<template v-if="action.icon" #icon>
<IconifyIcon :icon="action.icon" />
@@ -184,16 +184,21 @@ function handleMenuClick(e: any) {
<Dropdown v-if="getDropdownList.length > 0" :trigger="['hover']">
<slot name="more">
<Button size="small" type="link">
<Button :type="getDropdownList[0]?.type">
<template #icon>
{{ $t('page.action.more') }}
<IconifyIcon class="icon-more" icon="ant-design:more-outlined" />
<IconifyIcon icon="lucide:ellipsis-vertical" />
</template>
</Button>
</slot>
<template #overlay>
<Menu @click="handleMenuClick">
<Menu.Item v-for="(action, index) in getDropdownList" :key="index">
<Menu>
<Menu.Item
v-for="(action, index) in getDropdownList"
:key="index"
:disabled="action.disabled"
@click="!action.popConfirm && handleMenuClick({ key: index })"
>
<template v-if="action.popConfirm">
<Popconfirm v-bind="getPopConfirmProps(action.popConfirm)">
<template v-if="action.popConfirm.icon" #icon>
@@ -207,7 +212,9 @@ function handleMenuClick(e: any) {
"
>
<IconifyIcon v-if="action.icon" :icon="action.icon" />
<span class="ml-1">{{ action.text }}</span>
<span :class="action.icon ? 'ml-1' : ''">
{{ action.text }}
</span>
</div>
</Popconfirm>
</template>
@@ -229,9 +236,10 @@ function handleMenuClick(e: any) {
</Dropdown>
</div>
</template>
<style lang="scss">
.m-table-action {
.ant-btn {
.table-actions {
.ant-btn-link {
padding: 4px;
margin-left: 0;
}

View File

@@ -1,4 +1,7 @@
import type { ButtonProps } from 'ant-design-vue/es/button/buttonTypes';
import type {
ButtonProps,
ButtonType,
} from 'ant-design-vue/es/button/buttonTypes';
import type { TooltipProps } from 'ant-design-vue/es/tooltip/Tooltip';
export interface PopConfirm {
@@ -13,6 +16,7 @@ export interface PopConfirm {
export interface ActionItem extends ButtonProps {
onClick?: () => void;
type?: ButtonType;
label?: string;
color?: 'error' | 'success' | 'warning';
icon?: string;

View File

@@ -5,7 +5,7 @@ import type { VxeToolbarInstance } from '#/adapter/vxe-table';
import { ref } from 'vue';
import { useContentMaximize, useRefresh } from '@vben/hooks';
import { Expand, MsRefresh, Search, TMinimize } from '@vben/icons';
import { IconifyIcon } from '@vben/icons';
import { Button, Tooltip } from 'ant-design-vue';
@@ -41,37 +41,39 @@ defineExpose({
<slot></slot>
<Tooltip placement="bottom">
<template #title>
<div class="max-w-[200px]">搜索</div>
<div class="max-w-52">搜索</div>
</template>
<Button
class="ml-2 font-[8px]"
class="ml-2 font-normal"
shape="circle"
@click="onHiddenSearchBar"
>
<Search :size="15" />
<IconifyIcon icon="lucide:search" :size="15" />
</Button>
</Tooltip>
<Tooltip placement="bottom">
<template #title>
<div class="max-w-[200px]">刷新</div>
<div class="max-w-52">刷新</div>
</template>
<Button class="ml-2 font-[8px]" shape="circle" @click="refresh">
<MsRefresh :size="15" />
<Button class="ml-2 font-medium" shape="circle" @click="refresh">
<IconifyIcon icon="lucide:refresh-cw" :size="15" />
</Button>
</Tooltip>
<Tooltip placement="bottom">
<template #title>
<div class="max-w-[200px]">
<div class="max-w-52">
{{ contentIsMaximize ? '还原' : '全屏' }}
</div>
</template>
<Button
class="ml-2 font-[8px]"
class="ml-2 font-medium"
shape="circle"
@click="toggleMaximizeAndTabbarHidden"
>
<Expand v-if="!contentIsMaximize" :size="15" />
<TMinimize v-else :size="15" />
<IconifyIcon
:icon="contentIsMaximize ? 'lucide:minimize' : 'lucide:maximize'"
:size="15"
/>
</Button>
</Tooltip>
</template>

View File

@@ -2,7 +2,7 @@
import type { UploadFile, UploadProps } from 'ant-design-vue';
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
import type { AxiosResponse } from '@vben/request';
import type { FileUploadProps } from './typing';
import type { AxiosProgressEvent } from '#/api/infra/file';
@@ -20,45 +20,20 @@ import { useUpload, useUploadType } from './use-upload';
defineOptions({ name: 'FileUpload', inheritAttrs: false });
const props = withDefaults(
defineProps<{
// 根据后缀,或者其他
accept?: string[];
api?: (
file: File,
onUploadProgress?: AxiosProgressEvent,
) => Promise<AxiosResponse<any>>;
// 上传的目录
directory?: string;
disabled?: boolean;
helpText?: string;
// 最大数量的文件Infinity不限制
maxNumber?: number;
// 文件最大多少MB
maxSize?: number;
// 是否支持多选
multiple?: boolean;
// support xxx.xxx.xx
resultField?: string;
// 是否显示下面的描述
showDescription?: boolean;
value?: string | string[];
}>(),
{
value: () => [],
directory: undefined,
disabled: false,
helpText: '',
maxSize: 2,
maxNumber: 1,
accept: () => [],
multiple: false,
api: undefined,
resultField: '',
showDescription: false,
},
);
const emit = defineEmits(['change', 'update:value', 'delete']);
const props = withDefaults(defineProps<FileUploadProps>(), {
value: () => [],
directory: undefined,
disabled: false,
helpText: '',
maxSize: 2,
maxNumber: 1,
accept: () => [],
multiple: false,
api: undefined,
resultField: '',
showDescription: false,
});
const emit = defineEmits(['change', 'update:value', 'delete', 'returnText']);
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
const isInnerOperate = ref<boolean>(false);
const { getStringAccept } = useUploadType({
@@ -112,7 +87,7 @@ watch(
},
);
const handleRemove = async (file: UploadFile) => {
async function handleRemove(file: UploadFile) {
if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
index !== -1 && fileList.value.splice(index, 1);
@@ -122,9 +97,12 @@ const handleRemove = async (file: UploadFile) => {
emit('change', value);
emit('delete', file);
}
};
}
async function beforeUpload(file: File) {
const fileContent = await file.text();
emit('returnText', fileContent);
const beforeUpload = async (file: File) => {
const { maxSize, accept } = props;
const isAct = checkFileType(file, accept);
if (!isAct) {
@@ -141,7 +119,7 @@ const beforeUpload = async (file: File) => {
setTimeout(() => (isLtMsg.value = true), 1000);
}
return (isAct && !isLt) || Upload.LIST_IGNORE;
};
}
async function customRequest(info: UploadRequestOption<any>) {
let { api } = props;

View File

@@ -1,3 +1,8 @@
/**
* 默认图片类型
*/
export const defaultImageAccepts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
export function checkFileType(file: File, accepts: string[]) {
if (!accepts || accepts.length === 0) {
return true;
@@ -7,11 +12,6 @@ export function checkFileType(file: File, accepts: string[]) {
return reg.test(file.name);
}
/**
* 默认图片类型
*/
export const defaultImageAccepts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
export function checkImgType(
file: File,
accepts: string[] = defaultImageAccepts,

View File

@@ -2,15 +2,13 @@
import type { UploadFile, UploadProps } from 'ant-design-vue';
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
import type { AxiosResponse } from '@vben/request';
import type { UploadListType } from './typing';
import type { FileUploadProps } from './typing';
import type { AxiosProgressEvent } from '#/api/infra/file';
import { ref, toRefs, watch } from 'vue';
import { CloudUpload } from '@vben/icons';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { isFunction, isObject, isString } from '@vben/utils';
@@ -22,46 +20,20 @@ import { useUpload, useUploadType } from './use-upload';
defineOptions({ name: 'ImageUpload', inheritAttrs: false });
const props = withDefaults(
defineProps<{
// 根据后缀,或者其他
accept?: string[];
api?: (
file: File,
onUploadProgress?: AxiosProgressEvent,
) => Promise<AxiosResponse<any>>;
// 上传的目录
directory?: string;
disabled?: boolean;
helpText?: string;
listType?: UploadListType;
// 最大数量的文件Infinity不限制
maxNumber?: number;
// 文件最大多少MB
maxSize?: number;
// 是否支持多选
multiple?: boolean;
// support xxx.xxx.xx
resultField?: string;
// 是否显示下面的描述
showDescription?: boolean;
value?: string | string[];
}>(),
{
value: () => [],
directory: undefined,
disabled: false,
listType: 'picture-card',
helpText: '',
maxSize: 2,
maxNumber: 1,
accept: () => defaultImageAccepts,
multiple: false,
api: undefined,
resultField: '',
showDescription: true,
},
);
const props = withDefaults(defineProps<FileUploadProps>(), {
value: () => [],
directory: undefined,
disabled: false,
listType: 'picture-card',
helpText: '',
maxSize: 2,
maxNumber: 1,
accept: () => defaultImageAccepts,
multiple: false,
api: undefined,
resultField: '',
showDescription: true,
});
const emit = defineEmits(['change', 'update:value', 'delete']);
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
const isInnerOperate = ref<boolean>(false);
@@ -130,7 +102,7 @@ function getBase64<T extends ArrayBuffer | null | string>(file: File) {
});
}
const handlePreview = async (file: UploadFile) => {
async function handlePreview(file: UploadFile) {
if (!file.url && !file.preview) {
file.preview = await getBase64<string>(file.originFileObj!);
}
@@ -141,9 +113,9 @@ const handlePreview = async (file: UploadFile) => {
previewImage.value.slice(
Math.max(0, previewImage.value.lastIndexOf('/') + 1),
);
};
}
const handleRemove = async (file: UploadFile) => {
async function handleRemove(file: UploadFile) {
if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
index !== -1 && fileList.value.splice(index, 1);
@@ -153,14 +125,14 @@ const handleRemove = async (file: UploadFile) => {
emit('change', value);
emit('delete', file);
}
};
}
const handleCancel = () => {
function handleCancel() {
previewOpen.value = false;
previewTitle.value = '';
};
}
const beforeUpload = async (file: File) => {
async function beforeUpload(file: File) {
const { maxSize, accept } = props;
const isAct = checkImgType(file, accept);
if (!isAct) {
@@ -177,7 +149,7 @@ const beforeUpload = async (file: File) => {
setTimeout(() => (isLtMsg.value = true), 1000);
}
return (isAct && !isLt) || Upload.LIST_IGNORE;
};
}
async function customRequest(info: UploadRequestOption<any>) {
let { api } = props;
@@ -242,13 +214,13 @@ function getValue() {
v-if="fileList && fileList.length < maxNumber"
class="flex flex-col items-center justify-center"
>
<CloudUpload />
<IconifyIcon icon="lucide:cloud-upload" />
<div class="mt-2">{{ $t('ui.upload.imgUpload') }}</div>
</div>
</Upload>
<div
v-if="showDescription"
class="mt-2 flex flex-wrap items-center text-[14px]"
class="mt-2 flex flex-wrap items-center text-sm"
>
请上传不超过
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>

View File

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

View File

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

View File

@@ -80,17 +80,17 @@ export function useUploadType({
}
// TODO @芋艿:目前保持和 admin-vue3 一致,后续可能重构
export const useUpload = (directory?: string) => {
export function useUpload(directory?: string) {
// 后端上传地址
const uploadUrl = getUploadUrl();
// 是否使用前端直连上传
const isClientUpload =
UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE;
// 重写ElUpload上传方法
const httpRequest = async (
async function httpRequest(
file: File,
onUploadProgress?: AxiosProgressEvent,
) => {
) {
// 模式一:前端上传
if (isClientUpload) {
// 1.1 生成文件名称
@@ -114,20 +114,20 @@ export const useUpload = (directory?: string) => {
// 模式二:后端上传
return uploadFile({ file, directory }, onUploadProgress);
}
};
}
return {
uploadUrl,
httpRequest,
};
};
}
/**
* 获得上传 URL
*/
export const getUploadUrl = (): string => {
export function getUploadUrl(): string {
return `${apiURL}/infra/file/upload`;
};
}
/**
* 创建文件信息
@@ -135,7 +135,10 @@ export const getUploadUrl = (): string => {
* @param vo 文件预签名信息
* @param file 文件
*/
function createFile0(vo: InfraFileApi.FilePresignedUrlRespVO, file: File) {
function createFile0(
vo: InfraFileApi.FilePresignedUrlResp,
file: File,
): InfraFileApi.File {
const fileVO = {
configId: vo.configId,
url: vo.url,