♻️ clean up useless components / upgrade dependencies

This commit is contained in:
2025-02-19 00:21:32 +08:00
parent 5c0009f0b4
commit 3995884adc
87 changed files with 869 additions and 21258 deletions

72
components.d.ts vendored
View File

@@ -12,6 +12,7 @@ declare module 'vue' {
ABadge: typeof import('ant-design-vue/es')['Badge']
AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card']
ACascader: typeof import('ant-design-vue/es')['Cascader']
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
ADivider: typeof import('ant-design-vue/es')['Divider']
@@ -27,8 +28,8 @@ declare module 'vue' {
AImagePreviewGroup: typeof import('ant-design-vue/es')['ImagePreviewGroup']
AInput: typeof import('ant-design-vue/es')['Input']
AInputGroup: typeof import('ant-design-vue/es')['InputGroup']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
Alert: typeof import('./src/components/MyUI/Alert/Alert.vue')['default']
AList: typeof import('ant-design-vue/es')['List']
AListItem: typeof import('ant-design-vue/es')['ListItem']
AllPhoto: typeof import('./src/views/Photograph/AllPhoto/AllPhoto.vue')['default']
@@ -43,11 +44,14 @@ declare module 'vue' {
AQrcode: typeof import('ant-design-vue/es')['QRCode']
ARadio: typeof import('ant-design-vue/es')['Radio']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARangePicker: typeof import('ant-design-vue/es')['RangePicker']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
ASpace: typeof import('ant-design-vue/es')['Space']
ASpin: typeof import('ant-design-vue/es')['Spin']
ASwitch: typeof import('ant-design-vue/es')['Switch']
ATable: typeof import('ant-design-vue/es')['Table']
ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag']
@@ -55,61 +59,34 @@ declare module 'vue' {
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
AUpload: typeof import('ant-design-vue/es')['Upload']
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
Avatar: typeof import('./src/components/MyUI/Avatar/Avatar.vue')['default']
BackgroundAnimation: typeof import('./src/components/BackgroundAnimation/BackgroundAnimation.vue')['default']
BackTop: typeof import('./src/components/MyUI/BackTop/BackTop.vue')['default']
Badge: typeof import('./src/components/MyUI/Badge/Badge.vue')['default']
BlockOutlined: typeof import('@ant-design/icons-vue')['BlockOutlined']
BoxDog: typeof import('./src/components/BoxDog/BoxDog.vue')['default']
Breadcrumb: typeof import('./src/components/MyUI/Breadcrumb/Breadcrumb.vue')['default']
Button: typeof import('./src/components/MyUI/Button/Button.vue')['default']
Card: typeof import('./src/components/MyUI/Card/Card.vue')['default']
Card3D: typeof import('./src/components/Card3D/Card3D.vue')['default']
Carousel: typeof import('./src/components/MyUI/Carousel/Carousel.vue')['default']
Cascader: typeof import('./src/components/MyUI/Cascader/Cascader.vue')['default']
Checkbox: typeof import('./src/components/MyUI/Checkbox/Checkbox.vue')['default']
CheckCard: typeof import('./src/components/MyUI/CheckCard/CheckCard.vue')['default']
CheckCard: typeof import('./src/components/CheckCard/CheckCard.vue')['default']
CloseCircleOutlined: typeof import('@ant-design/icons-vue')['CloseCircleOutlined']
CloseOutlined: typeof import('@ant-design/icons-vue')['CloseOutlined']
Clouds: typeof import('./src/components/Clouds/Clouds.vue')['default']
Col: typeof import('./src/components/MyUI/Grid/Col.vue')['default']
Collapse: typeof import('./src/components/MyUI/Collapse/Collapse.vue')['default']
CommentInput: typeof import('./src/components/CommentReply/src/CommentInput/CommentInput.vue')['default']
CommentList: typeof import('./src/components/CommentReply/src/CommentList/CommentList.vue')['default']
CommentReply: typeof import('./src/components/CommentReply/index.vue')['default']
CompareImage: typeof import('./src/views/Upscale/CompareImage.vue')['default']
Countdown: typeof import('./src/components/MyUI/Countdown/Countdown.vue')['default']
DatePicker: typeof import('./src/components/MyUI/DatePicker/DatePicker.vue')['default']
DeleteOutlined: typeof import('@ant-design/icons-vue')['DeleteOutlined']
Descriptions: typeof import('./src/components/MyUI/Descriptions/Descriptions.vue')['default']
Dialog: typeof import('./src/components/MyUI/Dialog/Dialog.vue')['default']
Divider: typeof import('./src/components/MyUI/Divider/Divider.vue')['default']
DownloadOutlined: typeof import('@ant-design/icons-vue')['DownloadOutlined']
DownOutlined: typeof import('@ant-design/icons-vue')['DownOutlined']
Drawer: typeof import('./src/components/MyUI/Drawer/Drawer.vue')['default']
DynamicTitle: typeof import('./src/components/DynamicTitle/DynamicTitle.vue')['default']
Ellipsis: typeof import('./src/components/MyUI/Ellipsis/Ellipsis.vue')['default']
Empty: typeof import('./src/components/MyUI/Empty/Empty.vue')['default']
EyeInvisibleOutlined: typeof import('@ant-design/icons-vue')['EyeInvisibleOutlined']
EyeOutlined: typeof import('@ant-design/icons-vue')['EyeOutlined']
FileImageOutlined: typeof import('@ant-design/icons-vue')['FileImageOutlined']
Flex: typeof import('./src/components/MyUI/Flex/Flex.vue')['default']
FloatButton: typeof import('./src/components/MyUI/FloatButton/FloatButton.vue')['default']
Folder: typeof import('./src/components/Folder/Folder.vue')['default']
ForgetPage: typeof import('./src/views/Forget/ForgetPage.vue')['default']
GaugeChart: typeof import('./src/components/MyUI/GaugeChart/GaugeChart.vue')['default']
GradientText: typeof import('./src/components/MyUI/GradientText/GradientText.vue')['default']
Image: typeof import('./src/components/MyUI/Image/Image.vue')['default']
ImageShare: typeof import('./src/views/ImageShare/ImageShare.vue')['default']
ImageToolbar: typeof import('./src/views/Photograph/ImageToolbar/ImageToolbar.vue')['default']
ImageUpload: typeof import('./src/views/Photograph/ImageUpload/ImageUpload.vue')['default']
ImageWaterfall: typeof import('./src/components/MyUI/Waterfall/ImageWaterfall.vue')['default']
Input: typeof import('./src/components/MyUI/Input/Input.vue')['default']
InputSearch: typeof import('./src/components/MyUI/InputSearch/InputSearch.vue')['default']
InboxOutlined: typeof import('@ant-design/icons-vue')['InboxOutlined']
LandingPage: typeof import('./src/views/Landing/LandingPage.vue')['default']
LeftOutlined: typeof import('@ant-design/icons-vue')['LeftOutlined']
List: typeof import('./src/components/MyUI/List/List.vue')['default']
LoadingBar: typeof import('./src/components/MyUI/LoadingBar/LoadingBar.vue')['default']
LoadingGraphic: typeof import('./src/components/LoadingGraphic/LoadingGraphic.vue')['default']
LocationAlbum: typeof import('./src/views/Album/LocationAlbum/LocationAlbum.vue')['default']
LocationAlbumDetail: typeof import('./src/views/Album/LocationAlbum/LocationAlbumDetail.vue')['default']
@@ -118,14 +95,9 @@ declare module 'vue' {
LoginFooter: typeof import('./src/views/Login/LoginFooter.vue')['default']
LoginPage: typeof import('./src/views/Login/LoginPage.vue')['default']
MainPage: typeof import('./src/views/Main/MainPage.vue')['default']
Message: typeof import('./src/components/MyUI/Message/Message.vue')['default']
MessageReport: typeof import('./src/components/CommentReply/src/MessageReport/MessageReport.vue')['default']
Modal: typeof import('./src/components/MyUI/Modal/Modal.vue')['default']
NotFound: typeof import('./src/views/404/NotFound.vue')['default']
Notification: typeof import('./src/components/MyUI/Notification/Notification.vue')['default']
NumberAnimation: typeof import('./src/components/MyUI/NumberAnimation/NumberAnimation.vue')['default']
OrderedListOutlined: typeof import('@ant-design/icons-vue')['OrderedListOutlined']
Pagination: typeof import('./src/components/MyUI/Pagination/Pagination.vue')['default']
ParameterSetting: typeof import('./src/views/Upscale/ParameterSetting.vue')['default']
PeopleAlbum: typeof import('./src/views/Album/PeopleAlbum/PeopleAlbum.vue')['default']
PeopleAlbumDetail: typeof import('./src/views/Album/PeopleAlbum/PeopleAlbumDetail.vue')['default']
@@ -134,63 +106,35 @@ declare module 'vue' {
PhoalbumDetail: typeof import('./src/views/Album/Phoalbum/PhoalbumDetail.vue')['default']
PhoalbumList: typeof import('./src/views/Album/Phoalbum/PhoalbumList.vue')['default']
PhoneUpload: typeof import('./src/views/PhoneUpload/PhoneUpload.vue')['default']
PhotoStack: typeof import('./src/components/MyUI/PhotoStack/PhotoStack.vue')['default']
PhotoStack: typeof import('./src/components/PhotoStack/PhotoStack.vue')['default']
PlusOutlined: typeof import('@ant-design/icons-vue')['PlusOutlined']
PlusSquareOutlined: typeof import('@ant-design/icons-vue')['PlusSquareOutlined']
Popconfirm: typeof import('./src/components/MyUI/Popconfirm/Popconfirm.vue')['default']
Popover: typeof import('./src/components/MyUI/Popover/Popover.vue')['default']
Progress: typeof import('./src/components/MyUI/Progress/Progress.vue')['default']
QRCode: typeof import('./src/components/MyUI/QRCode/QRCode.vue')['default']
QRLogin: typeof import('./src/views/QRLogin/QRLogin.vue')['default']
QRLoginFooter: typeof import('./src/views/QRLogin/QRLoginFooter.vue')['default']
Radio: typeof import('./src/components/MyUI/Radio/Radio.vue')['default']
Rate: typeof import('./src/components/MyUI/Rate/Rate.vue')['default']
RecentUpload: typeof import('./src/views/Photograph/RecentUpload/RecentUpload.vue')['default']
RecyclingBin: typeof import('./src/views/RecyclingBin/RecyclingBin.vue')['default']
ReplyInput: typeof import('./src/components/CommentReply/src/ReplyInput/ReplyInput.vue')['default']
ReplyList: typeof import('./src/components/CommentReply/src/ReplyList/ReplyList.vue')['default']
ReplyReply: typeof import('./src/components/CommentReply/src/ReplyReplyInput/ReplyReply.vue')['default']
Result: typeof import('./src/components/MyUI/Result/Result.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Row: typeof import('./src/components/MyUI/Grid/Row.vue')['default']
SafetyOutlined: typeof import('@ant-design/icons-vue')['SafetyOutlined']
Scrollbar: typeof import('./src/components/MyUI/Scrollbar/Scrollbar.vue')['default']
SearchOutlined: typeof import('@ant-design/icons-vue')['SearchOutlined']
Segmented: typeof import('./src/components/MyUI/Segmented/Segmented.vue')['default']
Select: typeof import('./src/components/MyUI/Select/Select.vue')['default']
SendOutlined: typeof import('@ant-design/icons-vue')['SendOutlined']
ShareAltOutlined: typeof import('@ant-design/icons-vue')['ShareAltOutlined']
Skeleton: typeof import('./src/components/MyUI/Skeleton/Skeleton.vue')['default']
Slider: typeof import('./src/components/MyUI/Slider/Slider.vue')['default']
Space: typeof import('./src/components/MyUI/Space/Space.vue')['default']
Spin: typeof import('./src/components/MyUI/Spin/Spin.vue')['default']
StarButton: typeof import('./src/components/StarButton/StarButton.vue')['default']
Statistic: typeof import('./src/components/MyUI/Statistic/Statistic.vue')['default']
Steps: typeof import('./src/components/MyUI/Steps/Steps.vue')['default']
Swiper: typeof import('./src/components/MyUI/Swiper/Swiper.vue')['default']
Switch: typeof import('./src/components/MyUI/Switch/Switch.vue')['default']
Table: typeof import('./src/components/MyUI/Table/Table.vue')['default']
TabletOutlined: typeof import('@ant-design/icons-vue')['TabletOutlined']
Tabs: typeof import('./src/components/MyUI/Tabs/Tabs.vue')['default']
Tag: typeof import('./src/components/MyUI/Tag/Tag.vue')['default']
Textarea: typeof import('./src/components/MyUI/Textarea/Textarea.vue')['default']
TextScroll: typeof import('./src/components/MyUI/TextScroll/TextScroll.vue')['default']
ThingAlbum: typeof import('./src/views/Album/ThingAlbum/ThingAlbum.vue')['default']
ThingAlbumDetail: typeof import('./src/views/Album/ThingAlbum/ThingAlbumDetail.vue')['default']
ThingAlbumList: typeof import('./src/views/Album/ThingAlbum/ThingAlbumList.vue')['default']
Timeline: typeof import('./src/components/MyUI/Timeline/Timeline.vue')['default']
Tooltip: typeof import('./src/components/MyUI/Tooltip/Tooltip.vue')['default']
TreeChart: typeof import('./src/components/MyUI/TreeChart/TreeChart.vue')['default']
Upload: typeof import('./src/components/MyUI/Upload/Upload.vue')['default']
UploadImage: typeof import('./src/views/Upscale/UploadImage.vue')['default']
Upscale: typeof import('./src/views/Upscale/index.vue')['default']
UserInfoCard: typeof import('./src/components/CommentReply/src/UserInfoCard/UserInfoCard.vue')['default']
UserOutlined: typeof import('@ant-design/icons-vue')['UserOutlined']
Video: typeof import('./src/components/MyUI/Video/Video.vue')['default']
VueCompareImage: typeof import('./src/components/VueCompareImage/VueCompareImage.vue')['default']
WarningOutlined: typeof import('@ant-design/icons-vue')['WarningOutlined']
Waterfall: typeof import('./src/views/Photograph/WaterfallList/Waterfall.vue')['default']
WaterfallList: typeof import('./src/views/Photograph/WaterfallList/WaterfallList.vue')['default']
}
}

View File

@@ -30,12 +30,12 @@
"@types/animejs": "^3.1.12",
"@types/crypto-js": "^4.2.2",
"@types/json-stringify-safe": "^5.0.3",
"@types/node": "^22.13.1",
"@types/node": "^22.13.4",
"@types/nprogress": "^0.2.3",
"@vladmandic/face-api": "^1.7.15",
"@vuepic/vue-datepicker": "^11.0.1",
"@vueuse/core": "^12.5.0",
"@vueuse/integrations": "^12.5.0",
"@vueuse/core": "^12.7.0",
"@vueuse/integrations": "^12.7.0",
"alova": "^3.2.8",
"animejs": "^3.2.2",
"ant-design-vue": "^4.2.6",
@@ -44,9 +44,9 @@
"buffer": "^6.0.3",
"crypto-js": "^4.2.0",
"echarts": "^5.6.0",
"eslint": "9.20.0",
"eslint": "9.20.1",
"exifr": "^7.1.3",
"go-captcha-vue": "^2.0.5",
"go-captcha-vue": "^2.0.6",
"gsap": "^3.12.7",
"jsencrypt": "^3.3.2",
"json-stringify-safe": "^5.0.1",
@@ -54,13 +54,13 @@
"localforage": "^1.10.0",
"nprogress": "^0.2.0",
"nsfwjs": "^4.2.1",
"pinia": "^2.3.1",
"pinia": "^3.0.1",
"pinia-plugin-persistedstate-2": "^2.0.28",
"qrcode": "^1",
"rimraf": "^6.0.1",
"seedrandom": "^3.0.5",
"swiper": "^11.2.2",
"unplugin-auto-import": "^19.0.0",
"swiper": "^11.2.4",
"unplugin-auto-import": "^19.1.0",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-html": "^3.2.2",
"vite-plugin-node-polyfills": "^0.23.0",
@@ -75,15 +75,15 @@
"@eslint/js": "^9.20.0",
"@vitejs/plugin-vue": "^5.2.1",
"eslint-plugin-vue": "^9.32.0",
"globals": "^15.14.0",
"sass": "^1.84.0",
"globals": "^15.15.0",
"sass": "^1.85.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.23.0",
"unplugin-vue-components": "^28.0.0",
"typescript-eslint": "^8.24.1",
"unplugin-vue-components": "^28.2.0",
"vite": "^6.1.0",
"vite-plugin-bundle-obfuscator": "1.4.1",
"vite-plugin-chunk-split": "^0.5.0",
"vue-tsc": "2.2.0"
"vue-tsc": "2.2.2"
},
"overrides": {
"vite-plugin-chunk-split": {

View File

@@ -329,3 +329,18 @@ export const getSingleImageApi = (id: number) => {
},
});
};
/**
* 获取用户配置列表
*/
export const getStorageConfigListApi = () => {
return service.Post('/api/auth/storage/user/config/list', {}, {
cacheFor: {
expire: 60 * 60 * 24 * 7,
mode: "restore",
},
meta: {
ignoreToken: false,
signature: false,
},
});
};

View File

@@ -76,3 +76,7 @@ html {
}
}
// 取消antd table 最后一行的边框
.ant-table-wrapper .ant-table:not(.ant-table-bordered) .ant-table-tbody > tr:last-child > td {
border-bottom: none !important;
}

View File

@@ -0,0 +1 @@
<svg t="1739782785824" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13162" width="200" height="200"><path d="M512 803.84c-225.28 0-409.6-61.44-409.6-138.24v220.16c5.12 76.8 184.32 138.24 409.6 138.24 220.16 0 404.48-61.44 409.6-138.24V665.6c0 76.8-184.32 138.24-409.6 138.24z" fill="#5F6190" p-id="13163"></path><path d="M512 542.72c-225.28 0-409.6-61.44-409.6-138.24v220.16c5.12 76.8 184.32 138.24 409.6 138.24 220.16 0 404.48-61.44 409.6-138.24V404.48c0 76.8-184.32 138.24-409.6 138.24z" fill="#5F6190" p-id="13164"></path><path d="M921.6 138.24C921.6 61.44 737.28 0 512 0S102.4 61.44 102.4 138.24V358.4c5.12 76.8 184.32 138.24 409.6 138.24 220.16 0 404.48-61.44 409.6-138.24V138.24z m-409.6 97.28c-168.96 0-307.2-46.08-307.2-102.4s138.24-102.4 307.2-102.4 307.2 46.08 307.2 102.4-138.24 102.4-307.2 102.4z" fill="#1BC4DB" p-id="13165"></path></svg>

After

Width:  |  Height:  |  Size: 899 B

View File

@@ -0,0 +1 @@
<svg t="1739851505226" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1585" width="200" height="200"><path d="M880.64 174.08H455.68l-10.24-40.96c-5.12-20.48-20.48-30.72-40.96-30.72H143.36c-25.6 0-40.96 20.48-40.96 40.96v737.28c0 20.48 15.36 40.96 40.96 40.96h737.28c20.48 0 40.96-20.48 40.96-40.96v-665.6c0-20.48-20.48-40.96-40.96-40.96z" fill="#FFAA16" p-id="1586"></path><path d="M839.68 870.4H179.2c-20.48 0-40.96-20.48-40.96-40.96V276.48c0-20.48 15.36-40.96 40.96-40.96h660.48c20.48 0 40.96 20.48 40.96 40.96v552.96c0 20.48-20.48 40.96-40.96 40.96z" fill="#FFFFFF" p-id="1587"></path><path d="M880.64 312.32H143.36c-25.6 0-40.96 20.48-40.96 40.96v527.36c0 20.48 15.36 40.96 40.96 40.96h737.28c20.48 0 40.96-20.48 40.96-40.96V353.28c0-20.48-20.48-40.96-40.96-40.96z" fill="#FFD969" p-id="1588"></path></svg>

After

Width:  |  Height:  |  Size: 856 B

View File

@@ -1,422 +0,0 @@
<script setup lang="ts">
import type {Slot} from 'vue';
import {computed, nextTick, ref} from 'vue';
import {useSlotsExist} from '../Utils';
interface Props {
message?: string // 警告提示内容 string | slot
description?: string // 警告提示的辅助性文字介绍 string | slot
type?: 'default' | 'success' | 'info' | 'warning' | 'error' // 警告提示的类型
bordered?: boolean // 是否显示边框
closable?: boolean // 是否显示关闭按钮
closeText?: string // 自定义关闭按钮 string | slot
icon?: string // 自定义图标showIcon 为 true 时有效 string | slot
showIcon?: boolean // 是否显示辅助图标
actions?: Slot // 自定义操作项 slot
}
const props = withDefaults(defineProps<Props>(), {
message: undefined,
description: undefined,
type: 'default',
bordered: true,
closable: false,
closeText: undefined,
icon: undefined,
showIcon: false,
actions: undefined
});
const alertRef = ref(); // alert 模板引用
const closeAlert = ref(false);
const emit = defineEmits(['close']);
const slotsExist = useSlotsExist(['icon', 'description', 'actions']);
const showSlotsIcon = computed(() => {
return slotsExist.icon || props.icon || ['success', 'info', 'warning', 'error'].includes(props.type);
});
const showDesc = computed(() => {
return slotsExist.description || props.description;
});
async function onClose(e: Event) {
alertRef.value.style.maxHeight = `${alertRef.value.offsetHeight}px`;
await nextTick();
closeAlert.value = true;
emit('close', e);
}
</script>
<template>
<Transition name="alert-motion">
<div
v-if="!closeAlert"
ref="alertRef"
class="m-alert"
:class="[
`alert-${type}`,
{
'alert-borderless': !bordered,
'alert-width-description': showDesc
}
]"
>
<template v-if="showIcon && showSlotsIcon">
<span v-if="!showDesc" class="m-alert-icon">
<slot name="icon">
<img v-if="icon" :src="icon" class="icon-img"/>
<svg
v-else-if="type === 'info'"
class="icon-svg"
focusable="false"
data-icon="info-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm32 664c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V456c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272zm-32-344a48.01 48.01 0 010-96 48.01 48.01 0 010 96z"
></path>
</svg>
<svg
v-else-if="type === 'success'"
class="icon-svg"
focusable="false"
data-icon="check-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm193.5 301.7l-210.6 292a31.8 31.8 0 01-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z"
></path>
</svg>
<svg
v-else-if="type === 'warning'"
class="icon-svg"
focusable="false"
data-icon="exclamation-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm-32 232c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V296zm32 440a48.01 48.01 0 010-96 48.01 48.01 0 010 96z"
></path>
</svg>
<svg
v-else-if="type === 'error'"
class="icon-svg"
focusable="false"
data-icon="close-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm165.4 618.2l-66-.3L512 563.4l-99.3 118.4-66.1.3c-4.4 0-8-3.5-8-8 0-1.9.7-3.7 1.9-5.2l130.1-155L340.5 359a8.32 8.32 0 01-1.9-5.2c0-4.4 3.6-8 8-8l66.1.3L512 464.6l99.3-118.4 66-.3c4.4 0 8 3.5 8 8 0 1.9-.7 3.7-1.9 5.2L553.5 514l130 155c1.2 1.5 1.9 3.3 1.9 5.2 0 4.4-3.6 8-8 8z"
></path>
</svg>
</slot>
</span>
<span v-else class="m-big-icon">
<slot name="icon">
<img v-if="icon" :src="icon" class="big-icon-img"/>
<svg
v-else-if="type === 'info'"
class="icon-svg"
focusable="false"
data-icon="info-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z"
></path>
<path
d="M464 336a48 48 0 1096 0 48 48 0 10-96 0zm72 112h-48c-4.4 0-8 3.6-8 8v272c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V456c0-4.4-3.6-8-8-8z"
></path>
</svg>
<svg
v-else-if="type === 'success'"
class="icon-svg"
focusable="false"
data-icon="check-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M699 353h-46.9c-10.2 0-19.9 4.9-25.9 13.3L469 584.3l-71.2-98.8c-6-8.3-15.6-13.3-25.9-13.3H325c-6.5 0-10.3 7.4-6.5 12.7l124.6 172.8a31.8 31.8 0 0051.7 0l210.6-292c3.9-5.3.1-12.7-6.4-12.7z"
></path>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z"
></path>
</svg>
<svg
v-else-if="type === 'warning'"
class="icon-svg"
focusable="false"
data-icon="exclamation-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z"
></path>
<path
d="M464 688a48 48 0 1096 0 48 48 0 10-96 0zm24-112h48c4.4 0 8-3.6 8-8V296c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8v272c0 4.4 3.6 8 8 8z"
></path>
</svg>
<svg
v-else-if="type === 'error'"
class="icon-svg"
focusable="false"
data-icon="close-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M685.4 354.8c0-4.4-3.6-8-8-8l-66 .3L512 465.6l-99.3-118.4-66.1-.3c-4.4 0-8 3.5-8 8 0 1.9.7 3.7 1.9 5.2l130.1 155L340.5 670a8.32 8.32 0 00-1.9 5.2c0 4.4 3.6 8 8 8l66.1-.3L512 564.4l99.3 118.4 66 .3c4.4 0 8-3.5 8-8 0-1.9-.7-3.7-1.9-5.2L553.5 515l130.1-155c1.2-1.4 1.8-3.3 1.8-5.2z"
></path>
<path
d="M512 65C264.6 65 64 265.6 64 513s200.6 448 448 448 448-200.6 448-448S759.4 65 512 65zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z"
></path>
</svg>
</slot>
</span>
</template>
<div class="m-alert-content">
<div class="alert-message">
<slot>{{ message }}</slot>
</div>
<div v-if="showDesc" class="alert-description">
<slot name="description">{{ description }}</slot>
</div>
</div>
<div v-if="slotsExist.actions" class="m-alert-actions">
<slot name="actions"></slot>
</div>
<a v-if="closable" tabindex="0" class="m-alert-close" @click="onClose" @keydown.enter.prevent="onClose">
<slot name="closeText">
<span v-if="closeText">{{ closeText }}</span>
<svg
v-else
class="alert-close"
focusable="false"
data-icon="close"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M563.8 512l262.5-312.9c4.4-5.2.7-13.1-6.1-13.1h-79.8c-4.7 0-9.2 2.1-12.3 5.7L511.6 449.8 295.1 191.7c-3-3.6-7.5-5.7-12.3-5.7H203c-6.8 0-10.5 7.9-6.1 13.1L459.4 512 196.9 824.9A7.95 7.95 0 00203 838h79.8c4.7 0 9.2-2.1 12.3-5.7l216.5-258.1 216.5 258.1c3 3.6 7.5 5.7 12.3 5.7h79.8c6.8 0 10.5-7.9 6.1-13.1L563.8 512z"
></path>
</svg>
</slot>
</a>
</div>
</Transition>
</template>
<style lang="less" scoped>
.alert-motion-enter-active,
.alert-motion-leave-active {
overflow: hidden;
transition: max-height 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),
opacity 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),
padding 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
}
.alert-motion-leave-to {
max-height: 0 !important;
opacity: 0 !important;
padding-block: 0 !important;
}
.m-alert {
padding: 8px 12px;
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
line-height: 1.5714285714285714;
position: relative;
display: flex;
align-items: center;
word-break: break-all;
border-radius: 8px;
.m-alert-icon {
display: inline-block;
margin-right: 8px;
line-height: 0;
}
.m-big-icon {
margin-right: 12px;
font-size: 24px;
line-height: 0;
}
.icon-img {
display: inline-block;
width: 14px;
height: 14px;
}
.big-icon-img {
display: inline-block;
width: 24px;
height: 24px;
}
.icon-svg {
display: inline-block;
}
.m-alert-content {
flex: 1;
min-width: 0;
}
.m-alert-actions {
margin-left: 8px;
}
.m-alert-close {
margin-left: 8px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 12px;
cursor: pointer;
outline: none;
transition: color 0.2s;
&:hover {
color: rgba(0, 0, 0, 0.88);
}
.alert-close {
display: inline-block;
vertical-align: bottom;
fill: rgba(0, 0, 0, 0.45);
transition: fill 0.2s;
&:hover {
fill: rgba(0, 0, 0, 0.88);
}
}
}
}
.alert-default {
background-color: rgba(0, 0, 0, 0.02);
border: 1px solid #d9d9d9;
.m-alert-icon,
.m-big-icon {
color: rgba(0, 0, 0, 0.88);
.icon-svg,
:deep(svg) {
fill: currentColor;
}
}
}
.alert-info {
background-color: #e6f4ff;
border: 1px solid #91caff;
.m-alert-icon,
.m-big-icon {
color: @themeColor;
.icon-svg,
:deep(svg) {
fill: currentColor;
}
}
}
.alert-success {
background-color: #f6ffed;
border: 1px solid #b7eb8f;
.m-alert-icon,
.m-big-icon {
color: #52c41a;
.icon-svg,
:deep(svg) {
fill: currentColor;
}
}
}
.alert-warning {
background-color: #fffbe6;
border: 1px solid #ffe58f;
.m-alert-icon,
.m-big-icon {
color: #faad14;
.icon-svg,
:deep(svg) {
fill: currentColor;
}
}
}
.alert-error {
background-color: #fff2f0;
border: 1px solid #ffccc7;
.m-alert-icon,
.m-big-icon {
color: #ff4d4f;
.icon-svg,
:deep(svg) {
fill: currentColor;
}
}
}
.alert-borderless {
border: none;
}
.alert-width-description {
align-items: flex-start;
padding: 20px 24px;
.alert-message {
display: block;
margin-bottom: 8px;
color: rgba(0, 0, 0, 0.88);
font-size: 16px;
}
.alert-description {
display: block;
}
}
</style>

View File

@@ -1,216 +0,0 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import type { VNode, Slot } from 'vue';
import { useEventListener, useSlotsExist } from '../Utils';
interface Responsive {
xs?: number // <576px 响应式栅格
sm?: number // ≥576px 响应式栅格
md?: number // ≥768px 响应式栅格
lg?: number // ≥992px 响应式栅格
xl?: number // ≥1200px 响应式栅格
xxl?: number // ≥1600px 响应式栅格
}
interface Props {
color?: string // 头像的背景色
shape?: 'circle' | 'square' // 指定头像的形状
size?: number | 'small' | 'middle' | 'large' | Responsive // 设置头像的大小number 类型时单位 px
src?: string // 图片类头像资源地址
alt?: string // 图片无法显示时的替代文本
icon?: VNode | Slot // 设置头像的图标
href?: string // 点击跳转的地址,指定此属性按钮的行为和 a 链接一致
target?: '_self' | '_blank' // 相当于 a 标签的 target 属性href 存在时生效
}
const props = withDefaults(defineProps<Props>(), {
color: 'rgba(0, 0, 0, 0.25)',
shape: 'circle',
size: 'middle',
src: undefined,
alt: undefined,
icon: undefined,
href: undefined,
target: '_self'
});
const viewportWidth = ref(window.innerWidth);
function getViewportWidth() {
viewportWidth.value = window.innerWidth;
}
useEventListener(window, 'resize', getViewportWidth);
const slotsExist = useSlotsExist(['default', 'icon']);
const showIcon = computed(() => {
if (!props.src) {
return Boolean(slotsExist.icon || props.icon);
}
return false;
});
const avatarStyle = computed(() => {
if (typeof props.size === 'number') {
if (showIcon.value) {
return {
backgroundColor: props.color,
width: `${props.size}px`,
height: `${props.size}px`,
lineHeight: `${props.size}px`,
fontSize: `${props.size / 2}px`
};
} else {
return {
backgroundColor: props.color,
width: `${props.size}px`,
height: `${props.size}px`,
lineHeight: `${props.size}px`,
fontSize: '18px'
};
}
}
if (typeof props.size === 'object') {
let size = 32;
if (viewportWidth.value >= 1600 && props.size.xxl) {
size = props.size.xxl;
} else if (viewportWidth.value >= 1200 && props.size.xl) {
size = props.size.xl;
} else if (viewportWidth.value >= 992 && props.size.lg) {
size = props.size.lg;
} else if (viewportWidth.value >= 768 && props.size.md) {
size = props.size.md;
} else if (viewportWidth.value >= 576 && props.size.sm) {
size = props.size.sm;
} else if (viewportWidth.value < 576 && props.size.xs) {
size = props.size.xs;
}
return {
backgroundColor: props.color,
width: `${size}px`,
height: `${size}px`,
lineHeight: `${size}px`,
fontSize: `${size / 2}px`
};
}
return {
backgroundColor: props.color
};
});
const showStr = computed(() => {
if (!props.src && !showIcon.value) {
return slotsExist.default;
}
return false;
});
const strStyle = computed(() => {
if (typeof props.size === 'string') {
return {
transform: 'scale(1) translateX(-50%)'
};
}
if (typeof props.size === 'number') {
const scale = Math.min(1, Math.max(1 / 45, (1 + (props.size - 9) * 1) / 45));
return {
lineHeight: `${props.size}px`,
transform: `scale(${scale}) translateX(-50%)`
};
}
return {};
});
</script>
<template>
<component
:is="href ? 'a' : 'div'"
class="m-avatar"
:class="[
`avatar-${shape}`,
{
[`avatar-${size}`]: typeof size === 'string' && ['small', 'middle', 'large'].includes(size),
'avatar-image': src,
'avatar-link': href
}
]"
:style="avatarStyle"
:href="href"
:target="target"
>
<img v-if="src" class="image-item" :src="src" :alt="alt" />
<slot v-if="!src && showIcon" name="icon">
<component :is="icon" />
</slot>
<span v-if="!src && !showIcon && showStr" class="string-item" :style="strStyle">
<slot></slot>
</span>
</component>
</template>
<style lang="less" scoped>
.m-avatar {
position: relative;
display: inline-flex;
justify-content: center;
align-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 14px;
color: #fff;
line-height: 30px;
border: 1px solid transparent;
overflow: hidden;
white-space: nowrap;
cursor: auto;
outline: none;
&:hover {
color: #fff;
}
&.avatar-square {
border-radius: 6px;
}
.image-item {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
:deep(svg) {
width: 1em;
height: 1em;
fill: currentColor;
}
.string-item {
position: absolute;
left: 50%;
transform-origin: 0 center;
}
}
.avatar-small {
font-size: 14px;
width: 24px;
height: 24px;
line-height: 22px;
border-radius: 50%;
.avatar-icon {
font-size: 14px;
}
&.avatar-square {
border-radius: 4px;
}
}
.avatar-middle {
.avatar-icon {
font-size: 18px;
}
}
.avatar-large {
font-size: 24px;
width: 40px;
height: 40px;
line-height: 38px;
border-radius: 50%;
.avatar-icon {
font-size: 24px;
}
&.avatar-square {
border-radius: 8px;
}
}
.avatar-image {
background: transparent;
}
.avatar-link {
cursor: pointer;
}
</style>

View File

@@ -1,311 +0,0 @@
<script setup lang="ts">
import type {Slot, VNode} from 'vue';
import {computed, onBeforeUnmount, onMounted, ref, watch} from 'vue';
import Tooltip from '../Tooltip/Tooltip.vue';
import {useMutationObserver, useSlotsExist} from '../Utils';
interface Props {
icon?: VNode | Slot // 自定义图标
description?: string // 文字描述 string | slot
tooltip?: string // 文字提示内容 string | slot
tooltipProps?: object // Tooltip 组件属性配置,参考 Tooltip Props
type?: 'default' | 'primary' // 设置按钮类型
shape?: 'circle' | 'square' // 设置按钮形状
bottom?: number | string // BackTop 距离页面底部的高度,单位 px
right?: number | string // BackTop 距离页面右侧的宽度,单位 px
zIndex?: number // 设置 BackTop 的 z-index
visibilityHeight?: number // 滚动时触发显示回到顶部按钮的高度,单位 px
to?: string | HTMLElement // BackTop 渲染的容器节点,可选:元素标签名 (例如 'body') 或者元素本身,下同
listenTo?: string | HTMLElement // 监听滚动的元素,如果为 undefined 会监听距离最近的一个可滚动的祖先节点
}
const props = withDefaults(defineProps<Props>(), {
icon: undefined,
description: undefined,
tooltip: undefined,
tooltipProps: () => ({}),
type: 'default',
shape: 'circle',
bottom: 40,
right: 40,
zIndex: 9,
visibilityHeight: 180,
to: 'body',
listenTo: undefined
});
const backtopRef = ref<HTMLElement | null>(null);
const scrollTop = ref<number>(0); // 滚动距离
const scrollTarget = ref<HTMLElement | null>(null); // 滚动目标元素
const targetElement = ref<HTMLElement | null>(null); // 渲染容器元素
const emits = defineEmits(['click', 'show']);
const slotsExist = useSlotsExist(['tooltip', 'icon', 'description']);
const backTopStyle = computed(() => {
return {
bottom: typeof props.bottom === 'number' ? `${props.bottom}px` : props.bottom,
right: typeof props.right === 'number' ? `${props.right}px` : props.right,
zIndex: props.zIndex
};
});
const backTopShow = computed(() => {
return scrollTop.value >= props.visibilityHeight;
});
const showTooltip = computed(() => {
return slotsExist.tooltip || props.tooltip;
});
const showDescription = computed(() => {
return slotsExist.description || props.description;
});
watch(
() => props.to,
() => {
appendBackTop();
},
{
flush: 'post'
}
);
watch(
() => props.listenTo,
() => {
observeScroll();
},
{
flush: 'post'
}
);
watch(backTopShow, (to) => {
emits('show', to);
});
onMounted(() => {
observeScroll();
});
onBeforeUnmount(() => {
cleanup();
backtopRef.value?.remove();
});
const mutationObserver = useMutationObserver(
scrollTarget,
() => {
scrollTop.value = scrollTarget.value?.scrollTop ?? 0;
},
{subtree: true, childList: true, attributes: true, characterData: true}
);
function updateScrollTop(e: Event) {
scrollTop.value = (e.target as HTMLElement).scrollTop;
}
// 查询并监听滚动元素
function observeScroll() {
cleanup();
if (props.listenTo === undefined) {
scrollTarget.value = getScrollParent(backtopRef.value?.parentElement ?? null);
} else if (typeof props.listenTo === 'string') {
scrollTarget.value = document.getElementsByTagName(props.listenTo)[0] as HTMLElement;
} else if (props.listenTo instanceof HTMLElement) {
scrollTarget.value = props.listenTo;
}
if (scrollTarget.value) {
scrollTarget.value.addEventListener('scroll', updateScrollTop);
}
if (scrollTarget.value === document.documentElement) {
mutationObserver.start();
}
appendBackTop();
}
function cleanup() {
if (scrollTarget.value) {
scrollTarget.value.removeEventListener('scroll', updateScrollTop);
}
scrollTarget.value = null;
mutationObserver.stop();
}
function appendBackTop() {
// 渲染容器节点
if (typeof props.to === 'string') {
targetElement.value = document.getElementsByTagName(props.to)[0] as HTMLElement;
} else if (props.to instanceof HTMLElement) {
targetElement.value = props.to;
}
if (targetElement.value) {
targetElement.value?.appendChild(backtopRef.value!); // 保证 backtop 节点只存在一个
}
}
function getScrollParent(el: HTMLElement | null): HTMLElement | null {
const isScrollable = (el: HTMLElement): boolean => {
const style = window.getComputedStyle(el);
if (
el.scrollHeight > el.clientHeight &&
(['scroll', 'auto'].includes(style.overflowY) || el === document.documentElement)
) {
return true;
}
return false;
};
if (el) {
return isScrollable(el) ? el : getScrollParent(el.parentElement ?? null);
}
return null;
}
function onBackTop() {
if (scrollTarget.value) {
scrollTarget.value.scrollTo({
top: 0,
behavior: 'smooth' // 平滑滚动并产生过渡效果
});
}
emits('click');
}
</script>
<template>
<Transition name="zoom">
<div v-show="backTopShow" ref="backtopRef" class="m-backtop-wrap" :style="backTopStyle" @click="onBackTop">
<Tooltip style="border-radius: 22px" :content-style="{ borderRadius: '22px' }" v-bind="tooltipProps">
<template v-if="showTooltip" #tooltip>
<slot name="tooltip">{{ tooltip }}</slot>
</template>
<div class="m-backtop" :class="`backtop-${type} backtop-${shape}`">
<slot>
<span class="backtop-icon" :class="{ 'icon-description': showDescription }">
<slot name="icon">
<component v-if="icon" :is="icon"/>
<svg
v-else
width="1em"
height="1em"
viewBox="0 0 24 24"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xlinkHref="http://www.w3.org/1999/xlink"
>
<g stroke="none" stroke-width="1" fill-rule="evenodd">
<g transform="translate(-139.000000, -4423.000000)" fill-rule="nonzero">
<g transform="translate(120.000000, 4285.000000)">
<g transform="translate(7.000000, 126.000000)">
<g
transform="translate(24.000000, 24.000000) scale(1, -1) translate(-24.000000, -24.000000) translate(12.000000, 12.000000)"
>
<g transform="translate(4.000000, 2.000000)">
<path
d="M8,0 C8.51283584,0 8.93550716,0.38604019 8.99327227,0.883378875 L9,1 L9,10.584 L12.2928932,7.29289322 C12.6834175,6.90236893 13.3165825,6.90236893 13.7071068,7.29289322 C14.0675907,7.65337718 14.0953203,8.22060824 13.7902954,8.61289944 L13.7071068,8.70710678 L8.70710678,13.7071068 L8.62544899,13.7803112 L8.618,13.784 L8.59530661,13.8036654 L8.4840621,13.8753288 L8.37133602,13.9287745 L8.22929083,13.9735893 L8.14346259,13.9897165 L8.03324678,13.9994506 L7.9137692,13.9962979 L7.77070917,13.9735893 L7.6583843,13.9401293 L7.57677845,13.9063266 L7.47929125,13.8540045 L7.4048407,13.8036865 L7.38131006,13.7856883 C7.35030318,13.7612383 7.32077858,13.7349921 7.29289322,13.7071068 L2.29289322,8.70710678 L2.20970461,8.61289944 C1.90467972,8.22060824 1.93240926,7.65337718 2.29289322,7.29289322 C2.65337718,6.93240926 3.22060824,6.90467972 3.61289944,7.20970461 L3.70710678,7.29289322 L7,10.585 L7,1 L7.00672773,0.883378875 C7.06449284,0.38604019 7.48716416,0 8,0 Z"
></path>
<path
d="M14.9333333,15.9994506 C15.5224371,15.9994506 16,16.4471659 16,16.9994506 C16,17.5122865 15.5882238,17.9349578 15.0577292,17.9927229 L14.9333333,17.9994506 L1.06666667,17.9994506 C0.477562934,17.9994506 0,17.5517354 0,16.9994506 C0,16.4866148 0.411776203,16.0639435 0.9422708,16.0061783 L1.06666667,15.9994506 L14.9333333,15.9994506 Z"
></path>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>
</slot>
</span>
<span v-if="showDescription" class="backtop-description">
<slot name="description">{{ description }}</slot>
</span>
</slot>
</div>
</Tooltip>
</div>
</Transition>
</template>
<style lang="less" scoped>
.zoom-enter-active,
.zoom-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.zoom-enter-from,
.zoom-leave-to {
opacity: 0;
transform: scale(0.5);
}
.m-backtop-wrap {
position: fixed;
z-index: var(--z-index);
.m-backtop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 44px;
min-width: 44px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.backtop-icon {
display: inline-flex;
font-size: 26px;
:deep(svg) {
pointer-events: none;
fill: currentColor;
transition: color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
}
.icon-description {
font-size: 24px;
}
.backtop-description {
display: flex;
align-items: center;
font-size: 12px;
font-weight: 500;
line-height: 16px;
pointer-events: none;
transition: color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
}
.backtop-default {
color: rgba(0, 0, 0, 0.88);
background-color: rgba(255, 255, 255, 0.88);
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.12);
.backtop-icon,
.backtop-description {
color: rgba(0, 0, 0, 0.88);
}
&:hover {
color: #fff;
background-color: rgba(255, 255, 255);
box-shadow: 0 2px 8px 3px rgba(0, 0, 0, 0.12);
.backtop-icon,
.backtop-description {
color: #fff;
}
}
}
.backtop-primary {
color: #fff;
background-color: #4096ff;
box-shadow: 0 2px 8px 0 rgba(9, 88, 217, 0.32);
&:hover {
background-color: #4096ff;
box-shadow: 0 2px 8px 3px rgba(9, 88, 217, 0.32);
}
}
.backtop-circle {
border-radius: 22px;
}
.backtop-square {
border-radius: 8px;
}
}
</style>

View File

@@ -1,394 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { CSSProperties } from 'vue';
import { useSlotsExist } from '../Utils/index.ts';
enum PresetColor {
pink = 'pink',
red = 'red',
yellow = 'yellow',
orange = 'orange',
cyan = 'cyan',
green = 'green',
blue = 'blue',
purple = 'purple',
geekblue = 'geekblue',
magenta = 'magenta',
volcano = 'volcano',
gold = 'gold',
lime = 'lime'
}
enum Status {
success = 'success',
processing = 'processing',
default = 'default',
error = 'error',
warning = 'warning'
}
interface Props {
color?: PresetColor | string // 自定义小圆点的颜色,优先级高于 status
value?: number | string // 展示的数字或文字,为数字时大于 max 显示为 max+,为 0 时隐藏 number | string | slot
max?: number // 展示封顶的数字值
showZero?: boolean // 当数值为 0 时,是否展示 Badge
dot?: boolean // 不展示数字,只有一个小红点
offset?: [number | string, number | string] // 设置状态点的位置偏移,距默认位置左侧、上方的偏移量 [x, y]: [水平偏移, 垂直偏移]
status?: Status // 设置 Badge 为状态点
text?: string // 在设置了 status 或 color 的前提下有效,设置状态点的文本 string | slot
valueStyle?: CSSProperties // 设置徽标的样式
zIndex?: number // 设置徽标的 z-index
title?: string // 设置鼠标放在状态点上时显示的文字
ripple?: boolean // 是否开启涟漪动画效果
}
const props = withDefaults(defineProps<Props>(), {
color: undefined,
value: undefined,
max: 99,
showZero: false,
dot: false,
offset: undefined,
status: undefined,
text: undefined,
valueStyle: () => ({}),
zIndex: 9,
title: undefined,
ripple: true
});
const slotsExist = useSlotsExist(['default', 'value']);
const customStyle = computed(() => {
if (props.color && !Object.keys(PresetColor).includes(props.color)) {
if ((props.value !== undefined && props.value !== 0) || (props.showZero && props.value === 0)) {
return {
backgroundColor: props.color
};
} else {
return {
color: props.color,
backgroundColor: props.color
};
}
}
return {};
});
const presetClass = computed(() => {
if (props.color) {
if (Object.keys(PresetColor).includes(props.color)) {
if ((props.value !== undefined && props.value !== 0) || (props.showZero && props.value === 0)) {
return `color-${props.color} white`;
} else {
return `color-${props.color}`;
}
}
}
if (props.status) {
if ((props.value !== undefined && props.value !== 0) || (props.showZero && props.value === 0)) {
return `status-${props.status} white`;
} else {
return `status-${props.status}`;
}
}
return {};
});
const showContent = computed(() => {
if (props.value !== undefined || props.dot || (!props.color && !props.status)) {
return slotsExist.default;
}
return false;
});
const showValue = computed(() => {
if (!props.color && !props.status) {
return slotsExist.value;
}
return false;
});
const showBadge = computed(() => {
if ((props.value !== undefined && props.value !== 0) || (props.showZero && props.value === 0) || props.dot) {
return true;
}
return false;
});
const showDot = computed(() => {
return props.value === undefined || (props.value === 0 && !props.showZero) || props.dot;
});
const dotOffestStyle = computed(() => {
if (props.offset?.length) {
return {
right: isNumber(props.offset[0]) ? -props.offset[0] + 'px' : handleOffset(props.offset[0] as string),
marginTop: isNumber(props.offset[1]) ? props.offset[1] + 'px' : props.offset[1]
};
}
return {};
});
function isNumber(value: number | string): boolean {
return typeof value === 'number';
}
function handleOffset(value: string): string {
if (value.includes('-')) {
return value.replace('-', '');
} else {
return `-${value}`;
}
}
</script>
<template>
<div
class="m-badge"
:class="{ 'badge-status-color': value === undefined && (color || status) }"
:style="[`--z-index: ${zIndex}`, value === undefined && !dot ? dotOffestStyle : null]"
>
<template v-if="value === undefined && !dot && (color || status)">
<span class="status-dot" :class="[presetClass, { 'dot-ripple': ripple }]" :style="customStyle"></span>
<span class="status-text">
<slot>{{ text }}</slot>
</span>
</template>
<template v-else>
<template v-if="showContent">
<slot></slot>
</template>
<span v-if="showValue" class="m-value" :class="{ 'only-number': !showContent }">
<slot name="value"></slot>
</span>
<Transition name="zoom" v-else>
<div
v-if="showBadge"
class="m-badge-value"
:class="[
{
'small-num': typeof value === 'number' && value < 10,
'only-number': !showContent,
'only-dot': showDot
},
presetClass
]"
:style="[customStyle, dotOffestStyle, valueStyle]"
:title="title || (value !== undefined ? String(value) : '')"
>
<span v-if="!dot" class="m-number" style="transition: none 0s ease 0s">
<span class="u-number">{{ typeof value === 'number' && value > max ? max + '+' : value }}</span>
</span>
</div>
</Transition>
</template>
</div>
</template>
<style lang="less" scoped>
.zoom-enter-active {
animation: zoomBadgeIn 0.3s cubic-bezier(0.12, 0.4, 0.29, 1.46);
animation-fill-mode: both;
}
.zoom-leave-active {
animation: zoomBadgeOut 0.3s cubic-bezier(0.12, 0.4, 0.29, 1.46);
animation-fill-mode: both;
}
@keyframes zoomBadgeIn {
0% {
transform: scale(0) translate(50%, -50%);
opacity: 0;
}
100% {
transform: scale(1) translate(50%, -50%);
}
}
@keyframes zoomBadgeOut {
0% {
transform: scale(1) translate(50%, -50%);
}
100% {
transform: scale(0) translate(50%, -50%);
opacity: 0;
}
}
.m-badge {
position: relative;
display: inline-block;
width: fit-content;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1;
.status-dot {
position: relative;
top: -1px;
display: inline-block;
vertical-align: middle;
width: 6px;
height: 6px;
border-radius: 50%;
}
.dot-ripple {
&::after {
box-sizing: border-box;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-width: 1px;
border-style: solid;
border-color: inherit;
border-radius: 50%;
animation-name: dotRipple;
animation-duration: 1.2s;
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
content: '';
}
@keyframes dotRipple {
0% {
transform: scale(0.8);
opacity: 0.5;
}
100% {
transform: scale(2.4);
opacity: 0;
}
}
}
.status-text {
margin-left: 8px;
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
}
.m-value {
position: absolute;
top: 0;
z-index: var(--z-index);
right: 0;
transform: translate(50%, -50%);
transform-origin: 100% 0%;
}
.m-badge-value {
.m-value();
overflow: hidden;
padding: 0 8px;
min-width: 20px;
height: 20px;
color: #ffffff;
font-weight: normal;
font-size: 12px;
line-height: 20px;
white-space: nowrap;
text-align: center;
background: #ff4d4f;
border-radius: 10px;
box-shadow: 0 0 0 1px #ffffff;
transition: background 0.2s;
.m-number {
position: relative;
display: inline-block;
height: 20px;
transition: all 0.3s cubic-bezier(0.12, 0.4, 0.29, 1.46);
transform-style: preserve-3d;
-webkit-transform-style: preserve-3d; // 设置元素的子元素是位于 3D 空间中还是平面中 flat | preserve-3d
backface-visibility: hidden;
-webkit-backface-visibility: hidden; // 当元素背面朝向观察者时是否可见 hidden | visible
.u-number {
display: inline-block;
height: 20px;
margin: 0;
transform-style: preserve-3d;
-webkit-transform-style: preserve-3d;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
}
}
.small-num {
padding: 0;
}
.only-number {
position: relative;
top: auto;
display: block;
transform-origin: 50% 50%;
transform: none;
}
.only-dot {
width: 6px;
min-width: 6px;
height: 6px;
background: #ff4d4f;
border-radius: 100%;
box-shadow: 0 0 0 1px #ffffff;
padding: 0;
transition: background 0.3s;
}
.status-success {
color: #52c41a;
background-color: #52c41a;
}
.status-error {
color: #ff4d4f;
background-color: #ff4d4f;
}
.status-default {
color: rgba(0, 0, 0, 0.25);
background-color: rgba(0, 0, 0, 0.25);
}
.status-processing {
color: #1890ff;
background-color: #1890ff;
}
.status-warning {
color: #faad14;
background-color: #faad14;
}
.color-pink {
color: #eb2f96;
background-color: #eb2f96;
}
.color-red {
color: #f5222d;
background-color: #f5222d;
}
.color-yellow {
color: #fadb14;
background-color: #fadb14;
}
.color-orange {
color: #fa8c16;
background-color: #fa8c16;
}
.color-cyan {
color: #13c2c2;
background-color: #13c2c2;
}
.color-green {
color: #52c41a;
background-color: #52c41a;
}
.color-blue {
color: #1890ff;
background-color: #1890ff;
}
.color-purple {
color: #722ed1;
background-color: #722ed1;
}
.color-geekblue {
color: #2f54eb;
background-color: #2f54eb;
}
.color-magenta {
color: #eb2f96;
background-color: #eb2f96;
}
.color-volcano {
color: #fa541c;
background-color: #fa541c;
}
.color-gold {
color: #faad14;
background-color: #faad14;
}
.color-lime {
color: #a0d911;
background-color: #a0d911;
}
.white {
color: #ffffff;
}
}
.badge-status-color {
line-height: inherit;
vertical-align: baseline;
}
</style>

View File

@@ -1,148 +0,0 @@
<script setup lang="ts">
import type {CSSProperties} from 'vue';
import {computed} from 'vue';
interface Query {
[propName: string]: any // 添加一个字符串索引签名,用于包含带有任意数量的其他属性
}
interface Route {
name: string // 路由名称
path?: string // 路由地址
query?: Query // 路由查询参数
}
interface Props {
routes?: Route[] // router 路由数组
breadcrumbClass?: string // 设置面包屑类名
breadcrumbStyle?: CSSProperties // 设置面包屑样式
maxWidth?: string | number // 设置文本最大显示宽度,超出后显示省略号,单位 px
separator?: string // 自定义分隔符,默认为 > string | slot
separatorStyle?: CSSProperties // 设置分隔符样式
target?: '_self' | '_blank' // 如何打开目标 URL当前窗口或新窗口
}
const props = withDefaults(defineProps<Props>(), {
routes: () => [],
breadcrumbClass: undefined,
breadcrumbStyle: () => ({}),
maxWidth: '100%',
separator: undefined,
separatorStyle: () => ({}),
target: '_self'
});
const breadcrumbAmount = computed(() => {
return props.routes.length;
});
function getUrl(route: Route) {
let targetUrl: string = '';
if (route.path) {
targetUrl = route.path;
}
if (route.query && JSON.stringify(route.query) !== '{}') {
const query = route.query;
Object.keys(query).forEach((param, index) => {
if (index === 0) {
targetUrl = targetUrl + '?' + param + '=' + query[param];
} else {
targetUrl = targetUrl + '&' + param + '=' + query[param];
}
});
}
return targetUrl;
}
</script>
<template>
<div class="m-breadcrumb" :class="breadcrumbClass" :style="breadcrumbStyle">
<div class="m-breadcrumb-item" v-for="(route, index) in routes" :key="index">
<component
:is="route.path ? 'a' : 'span'"
class="breadcrumb-link"
:class="{
'link-hover': route.path,
'link-active': index === breadcrumbAmount - 1
}"
:style="`max-width: ${maxWidth}px;`"
:href="getUrl(route)"
:target="target"
:title="route.name"
>
{{ route.name }}
</component>
<span v-if="index < breadcrumbAmount - 1" class="breadcrumb-separator" :style="separatorStyle">
<slot name="separator" :index="index">
<span v-if="separator">{{ separator }}</span>
<svg
v-else
focusable="false"
data-icon="right"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M765.7 486.8L314.9 134.7A7.97 7.97 0 00302 141v77.3c0 4.9 2.3 9.6 6.1 12.6l360 281.1-360 281.1c-3.9 3-6.1 7.7-6.1 12.6V883c0 6.7 7.7 10.4 12.9 6.3l450.8-352.1a31.96 31.96 0 000-50.4z"
></path>
</svg>
</slot>
</span>
</div>
</div>
</template>
<style lang="less" scoped>
.m-breadcrumb {
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5714285714285714;
display: flex;
align-items: center;
flex-wrap: wrap;
.m-breadcrumb-item {
display: inline-flex;
align-items: center;
.breadcrumb-link {
display: inline-block;
color: rgba(0, 0, 0, 0.45);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: 0 4px;
border-radius: 4px;
text-decoration: none;
cursor: text;
transition: color 0.2s,
background-color 0.2s;
}
.link-hover {
cursor: pointer;
&:hover {
background-color: rgba(0, 0, 0, 0.06);
color: rgba(0, 0, 0, 0.88);
}
}
.link-active {
color: rgba(0, 0, 0, 0.88);
}
.breadcrumb-separator {
display: inline-flex;
align-items: center;
margin: 0 4px;
color: rgba(0, 0, 0, 0.45);
:deep(svg) {
margin-inline: -2px;
fill: currentColor;
}
}
}
}
</style>

View File

@@ -1,497 +0,0 @@
<script setup lang="ts">
import { ref, nextTick, computed } from 'vue';
import type { VNode, Slot } from 'vue';
import { useSlotsExist } from '../Utils';
interface Props {
type?: 'default' | 'reverse' | 'primary' | 'danger' | 'dashed' | 'text' | 'link' // 设置按钮类型
shape?: 'default' | 'circle' | 'round' // 设置按钮形状
icon?: VNode | Slot // 设置按钮图标
size?: 'small' | 'middle' | 'large' // 设置按钮尺寸
ghost?: boolean // 按钮背景是否透明,仅当 type: 'primary' | 'danger' 时生效
buttonClass?: string // 设置按钮类名
rippleColor?: string // 点击时的波纹颜色,一般不需要设置,默认会根据 type 自动匹配,主要用于自定义样式时且 type: 'default'
href?: string // 点击跳转的地址,与 a 链接的 href 属性一致
target?: '_self' | '_blank' // 如何打开目标链接,相当于 a 链接的 target 属性href 存在时生效
keyboard?: boolean // 是否支持键盘操作
disabled?: boolean // 是否禁用
loading?: boolean // 是否加载中
loadingType?: 'static' | 'dynamic' // 加载指示符类型
block?: boolean // 是否将按钮宽度调整为其父宽度
}
const props = withDefaults(defineProps<Props>(), {
type: 'default',
shape: 'default',
icon: undefined,
size: 'middle',
ghost: false,
rippleColor: undefined,
buttonClass: undefined,
href: undefined,
target: '_self',
keyboard: true,
disabled: false,
loading: false,
loadingType: 'dynamic',
block: false
});
const presetRippleColors = {
default: '#1677ff',
reverse: '#1677ff',
primary: '#1677ff',
danger: '#ff4d4f',
dashed: '#1677ff',
text: 'transparent',
link: 'transparent'
};
const wave = ref(false);
const emit = defineEmits(['click']);
const slotsExist = useSlotsExist(['icon', 'default']);
const showIcon = computed(() => {
return slotsExist.icon || props.icon;
});
const showIconOnly = computed(() => {
return showIcon.value && !slotsExist.default;
});
function onClick(e: Event) {
if (wave.value) {
wave.value = false;
nextTick(() => {
wave.value = true;
});
} else {
wave.value = true;
}
emit('click', e);
}
function onKeyboard(e: KeyboardEvent) {
onClick(e);
}
function onWaveEnd() {
wave.value = false;
}
</script>
<template>
<component
:is="href ? 'a' : 'div'"
tabindex="0"
class="m-btn"
:class="[
`btn-${type} btn-${size}`,
{
[`loading-${size}`]: !href && loading,
'btn-icon-only': showIconOnly,
'btn-circle': shape === 'circle',
'btn-round': shape === 'round',
'btn-loading-blur': !href && loading,
'btn-ghost': ghost,
'btn-block': block,
'btn-disabled': disabled
},
buttonClass
]"
:style="`--ripple-color: ${rippleColor || presetRippleColors[type]};`"
:href="href"
:target="target"
@click="disabled || loading ? () => false : onClick($event)"
@keydown.enter.prevent="keyboard && !disabled && !loading ? onKeyboard($event) : () => false"
>
<div v-if="loading || !showIcon" class="btn-loading">
<div v-if="!href && loadingType === 'static'" class="m-static-circle">
<svg class="circle" width="1em" height="1em" fill="currentColor" viewBox="0 0 100 100">
<path
d="M 50,50 m 0,-45 a 45,45 0 1 1 0,90 a 45,45 0 1 1 0,-90"
stroke-linecap="round"
class="path"
fill-opacity="0"
></path>
</svg>
</div>
<div v-if="!href && loadingType === 'dynamic'" class="m-dynamic-circle">
<svg class="circle" viewBox="0 0 50 50" width="1em" height="1em" fill="currentColor">
<circle class="path" cx="25" cy="25" r="20" fill="none"></circle>
</svg>
</div>
</div>
<span v-if="!loading && showIcon" class="btn-icon">
<slot name="icon">
<component v-if="icon" :is="icon" />
</slot>
</span>
<span v-if="slotsExist.default" class="btn-content">
<slot></slot>
</span>
<div v-if="!disabled" class="button-wave" :class="{ 'wave-active': wave }" @animationend="onWaveEnd"></div>
</component>
</template>
<style lang="less" scoped>
@primary: #1677ff;
@danger: #ff4d4f;
.m-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 400;
line-height: 1.5714285714285714;
color: rgba(0, 0, 0, 0.88);
white-space: nowrap;
text-align: center;
background-color: transparent;
border: 1px solid transparent;
outline: none;
user-select: none;
text-decoration: none;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
.btn-loading {
display: inline-flex;
align-items: center;
overflow: hidden;
opacity: 0;
width: 0;
transition:
margin-right 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
width 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
opacity 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
.m-static-circle,
.m-dynamic-circle {
display: inline-flex;
justify-content: start;
.circle .path {
stroke: currentColor;
}
}
.m-static-circle {
.circle {
animation: spin-circle 0.8s linear infinite;
-webkit-animation: spin-circle 0.8s linear infinite;
.path {
stroke-width: 10;
stroke-dashoffset: 0;
stroke-dasharray: 84.82px, 282.74px;
}
}
}
.m-dynamic-circle {
.circle {
animation: spin-circle 2s linear infinite;
-webkit-animation: spin-circle 2s linear infinite;
.path {
stroke-width: 5;
stroke-dasharray: 90, 150;
stroke-dashoffset: 0;
stroke-linecap: round;
animation: loading-dash 1.5s ease-in-out infinite;
-webkit-animation: loading-dash 1.5s ease-in-out infinite;
@keyframes loading-dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -40px;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -124px;
}
}
}
}
}
@keyframes spin-circle {
100% {
transform: rotate(360deg);
}
}
}
.btn-icon,
.btn-content {
display: inline-flex;
align-items: center;
:deep(svg) {
fill: currentColor;
}
}
.button-wave {
position: absolute;
pointer-events: none;
top: 0;
right: 0;
bottom: 0;
left: 0;
animation-iteration-count: 1;
animation-duration: 0.6s;
animation-timing-function: cubic-bezier(0, 0, 0.2, 1), cubic-bezier(0, 0, 0.2, 1);
border-radius: inherit;
}
.wave-active {
z-index: 1;
animation-name: wave-spread, wave-opacity;
@keyframes wave-spread {
from {
box-shadow: 0 0 0.5px 0 var(--ripple-color);
}
to {
box-shadow: 0 0 0.5px 5.5px var(--ripple-color);
}
}
@keyframes wave-opacity {
from {
opacity: 0.6;
}
to {
opacity: 0;
}
}
}
& > .btn-icon + .btn-content {
margin-left: 8px;
}
}
.btn-default {
background-color: #ffffff;
border-color: #d9d9d9;
&:hover {
color: #4096ff !important;
border-color: #4096ff;
}
&:active {
color: #0958d9 !important;
border-color: #0958d9;
}
.btn-icon {
:deep(svg) {
transition: fill 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
}
}
}
.btn-reverse {
.btn-default();
&:hover {
color: #fff !important;
background-color: #4096ff;
border-color: #4096ff;
}
&:active {
color: #fff !important;
background-color: #0958d9;
border-color: #0958d9;
}
}
.btn-primary {
color: #fff;
background-color: @primary;
border-color: @primary;
&:hover {
color: #fff;
background-color: #4096ff;
border-color: #4096ff;
}
&:active {
color: #fff;
background-color: #0958d9;
border-color: #0958d9;
}
}
.btn-danger {
color: #fff;
background-color: @danger;
border-color: @danger;
&:hover {
color: #fff;
background-color: #ff7875;
border-color: #ff7875;
}
&:active {
color: #fff;
background-color: #d9363e;
border-color: #d9363e;
}
}
.btn-dashed {
.btn-default();
border-style: dashed;
}
.btn-text {
&:hover {
background-color: rgba(0, 0, 0, 0.06);
}
&:active {
background-color: rgba(0, 0, 0, 0.15);
}
}
.btn-link {
color: @primary;
&:hover {
color: #4096ff;
}
&:active {
color: #0958d9;
}
.btn-icon {
:deep(svg) {
transition: color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
}
}
}
.btn-small {
font-size: 14px;
height: 24px;
padding: 0 7px;
border-radius: 4px;
}
.btn-middle {
font-size: 14px;
height: 32px;
padding: 4px 15px;
border-radius: 6px;
}
.btn-large {
font-size: 16px;
height: 40px;
padding: 6.428571428571429px 15px;
border-radius: 8px;
}
.loading-small,
.loading-middle {
.btn-loading {
margin-right: 8px;
width: 1em;
opacity: 1;
}
}
.loading-large {
.btn-loading {
margin-right: 8px;
width: 1em;
opacity: 1;
}
}
.btn-icon-only {
width: 32px;
padding-left: 0;
padding-right: 0;
.btn-loading,
.btn-icon {
transform: scale(1.143);
}
.btn-loading {
margin-right: 0;
}
}
.btn-small.btn-icon-only {
width: 24px;
padding-left: 0;
padding-right: 0;
}
.btn-large.btn-icon-only {
width: 40px;
padding-left: 0;
padding-right: 0;
}
.btn-circle {
min-width: 32px;
padding-left: 0;
padding-right: 0;
border-radius: 50%;
}
.btn-small.btn-circle {
min-width: 24px;
padding-left: 0;
padding-left: 0;
border-radius: 50%;
}
.btn-large.btn-circle {
min-width: 40px;
padding-left: 0;
padding-right: 0;
border-radius: 50%;
}
.btn-round {
border-radius: 32px;
padding-left: 16px;
padding-right: 16px;
}
.btn-small.btn-round {
border-radius: 24px;
padding-left: 12px;
padding-right: 12px;
}
.btn-large.btn-round {
border-radius: 40px;
padding-left: 20px;
padding-right: 20px;
}
.btn-icon-only.btn-round,
.btn-small.btn-icon-only.btn-round,
.btn-large.btn-icon-only.btn-round {
width: auto;
}
.btn-loading-blur {
opacity: 0.65;
pointer-events: none;
}
.btn-primary.btn-ghost:not(.btn-disabled) {
color: @primary;
border-color: @primary;
background-color: transparent;
&:hover {
color: #4096ff;
border-color: #4096ff;
}
&:active {
color: #0958d9;
border-color: #0958d9;
}
.btn-icon {
:deep(svg) {
transition: color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
}
}
}
.btn-danger.btn-ghost:not(.btn-disabled) {
color: @danger;
border-color: @danger;
background-color: transparent;
&:hover {
color: #ff7875;
border-color: #ff7875;
}
&:active {
color: #d9363e;
border-color: #d9363e;
}
.btn-icon {
:deep(svg) {
transition: color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
}
}
}
.btn-block {
width: 100%;
}
.btn-disabled {
color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
&:not(.btn-text, .btn-link) {
border-color: #d9d9d9;
background-color: rgba(0, 0, 0, 0.04);
}
&:not(.btn-text, .btn-link):hover,
&:not(.btn-text, .btn-link):active {
border-color: #d9d9d9;
color: rgba(0, 0, 0, 0.25) !important;
background-color: rgba(0, 0, 0, 0.04);
}
&.btn-text:hover,
&.btn-text:active {
background-color: transparent;
}
&.btn-link:hover,
&.btn-link:active {
color: rgba(0, 0, 0, 0.25);
}
}
</style>

View File

@@ -1,171 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { CSSProperties } from 'vue';
import Skeleton from '../Skeleton/Skeleton.vue';
import { useSlotsExist } from '../Utils';
interface Props {
width?: number | string // 卡片宽度,单位 px
bordered?: boolean // 是否有边框
size?: 'small' | 'middle' | 'large' // 卡片的尺寸
hoverable?: boolean // 鼠标移过时可浮起
loading?: boolean // 当卡片内容还在加载中时,可以用 loading 展示一个占位
skeletonProps?: object // 加载中时,骨架屏的属性配置,参考 Skeleton Props
title?: string // 卡片标题 string | slot
extra?: string // 卡片右上角的操作区域 string | slot
headStyle?: CSSProperties // 自定义标题区域样式
bodyStyle?: CSSProperties // 自定义内容区域样式
}
const props = withDefaults(defineProps<Props>(), {
width: 'auto',
bordered: true,
size: 'middle',
hoverable: false,
loading: false,
skeletonProps: () => ({}),
title: undefined,
extra: undefined,
headStyle: () => ({}),
bodyStyle: () => ({})
});
const slotsExist = useSlotsExist(['title', 'extra']);
const cardWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`;
}
return props.width;
});
const showHeader = computed(() => {
return slotsExist.title || slotsExist.extra || props.title || props.extra;
});
const showTitle = computed(() => {
return slotsExist.title || props.title;
});
const showExtra = computed(() => {
return slotsExist.extra || props.extra;
});
</script>
<template>
<div
class="m-card"
:class="{
'card-bordered': bordered,
'card-small': size === 'small',
'card-middle': size === 'middle',
'card-large': size === 'large',
'card-hoverable': hoverable
}"
:style="`width: ${cardWidth};`"
>
<div class="m-card-head" :style="headStyle" v-if="showHeader">
<div class="m-head-wrapper">
<div v-if="showTitle" class="head-title">
<slot name="title">{{ title }}</slot>
</div>
<div v-if="showExtra" class="head-extra">
<slot name="extra">{{ extra }}</slot>
</div>
</div>
</div>
<div class="m-card-body" :style="bodyStyle">
<Skeleton :title="false" :loading="loading" v-bind="skeletonProps">
<slot></slot>
</Skeleton>
</div>
</div>
</template>
<style lang="less" scoped>
.m-card {
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
position: relative;
background: #ffffff;
border-radius: 8px;
text-align: left;
transition: width 0.2s;
.m-card-head {
display: flex;
justify-content: center;
flex-direction: column;
margin-bottom: -1px;
color: rgba(0, 0, 0, 0.88);
font-weight: 600;
background: transparent;
border-bottom: 1px solid #f0f0f0;
border-radius: 8px 8px 0 0;
transition: all 0.2s;
.m-head-wrapper {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
.head-title {
display: inline-block;
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.head-extra {
margin-left: auto;
font-weight: normal;
font-size: 14px;
transition: font-size 0.2s;
}
}
}
.m-card-body {
border-radius: 0 0 8px 8px;
transition: padding 0.2s;
}
}
.card-bordered {
border: 1px solid #f0f0f0;
}
.card-small {
.m-card-head {
min-height: 38px;
padding: 0 12px;
font-size: 14px;
}
.m-card-body {
padding: 12px;
}
}
.card-middle {
.m-card-head {
min-height: 56px;
padding: 0 24px;
font-size: 16px;
}
.m-card-body {
padding: 24px;
}
}
.card-large {
font-size: 16px;
.m-card-head {
min-height: 74px;
padding: 0 36px;
font-size: 18px;
.m-head-wrapper .head-extra {
font-size: 16px;
}
}
.m-card-body {
padding: 36px;
}
}
.card-hoverable {
cursor: pointer;
transition:
box-shadow 0.2s,
border-color 0.2s;
&:hover {
box-shadow:
0 1px 2px -2px rgba(0, 0, 0, 0.16),
0 3px 6px 0 rgba(0, 0, 0, 0.12),
0 5px 12px 4px rgba(0, 0, 0, 0.09);
}
}
</style>

View File

@@ -1,658 +0,0 @@
<script setup lang="ts">
import type {CSSProperties} from 'vue';
import {computed, ref, watch} from 'vue';
import {cancelRaf, rafTimeout, useEventListener, useResizeObserver} from '../Utils';
import {useTransition} from '@vueuse/core';
import Spin from '../Spin/Spin.vue';
interface Image {
title?: string // 图片名称
src: string // 图片地址
link?: string // 图片跳转链接
}
interface Props {
images?: Image[] // 走马灯图片数组
width?: number | string // 走马灯宽度,单位 px
height?: number | string // 走马灯高度,单位 px
autoplay?: boolean // 是否自动轮播
pauseOnMouseEnter?: boolean // 当鼠标移入走马灯时,是否暂停自动轮播
effect?: 'slide' | 'fade' // 轮播图切换时的过渡效果
interval?: number // 自动轮播间隔,单位 ms
showArrow?: boolean // 是否显示箭头
arrowColor?: string // 箭头颜色
arrowSize?: number // 箭头大小,单位 px
dots?: boolean // 是否显示指示点
dotSize?: number // 指示点大小,单位 px
dotColor?: string // 指示点颜色
dotActiveColor?: string // 指示点选中颜色
dotStyle?: CSSProperties // 指示点样式,优先级高于 dotSize、dotColor
dotActiveStyle?: CSSProperties // 指示点选中样式,优先级高于 dotActiveColor
dotPosition?: 'bottom' | 'top' | 'left' | 'right' // 指示点位置,位置为 'left' | 'right' 时effect: 'slide' 轮播自动变为垂直轮播
dotsTrigger?: 'click' | 'hover' // 指示点触发切换的方式
spinProps?: object // 图片加载中样式Spin 组件属性配置,参考 Spin Props
fadeDuration?: number // 渐变动画持续时长,单位 ms仅当 effect 为 'fade' 时生效
fadeFunction?: string // 渐变动画函数,仅当 effect 为 'fade' 时生效,可参考 transition-timing-function 写法https://developer.mozilla.org/zh-CN/docs/Web/CSS/transition-timing-function
slideDuration?: number // 滑动动画持续时长,单位 ms仅当 effect 为 'slide' 时生效
slideFunction?: string | number[] // 滑动动画函数,仅当 effect 为 'slide' 时生效,可参考 useTransition 写法https://vueuse.org/core/useTransition/#usage
}
const props = withDefaults(defineProps<Props>(), {
images: () => [],
width: '100%',
height: '100vh',
autoplay: false,
pauseOnMouseEnter: false,
effect: 'slide',
interval: 3000,
showArrow: true,
arrowColor: '#FFF',
arrowSize: 36,
dots: true,
dotSize: 10,
dotColor: 'rgba(255, 255, 255, 0.3)',
dotActiveColor: '#1677FF',
dotStyle: () => ({}),
dotActiveStyle: () => ({}),
dotPosition: 'bottom',
dotsTrigger: 'click',
spinProps: () => ({}),
fadeDuration: 500,
fadeFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
slideDuration: 800,
slideFunction: () => [0.65, 0, 0.35, 1]
});
const offset = ref(0); // 滑动偏移值
const slideTimer = ref(); // 轮播切换定时器
const stopCarousel = ref(false); // 鼠标悬浮时,停止切换标志
const switchPrevent = ref(false); // 在滑动切换过程中,禁用其他所有切换操作
const moveEffectRaf = ref(); // 移动过程 requestAnimationFrame 的返回值,一个 long 整数,请求 ID是回调列表中唯一的标识
const targetPosition = ref(); // 目标移动位置
const carouselRef = ref(); // carousel DOM 引用
const activeSwitcher = ref(1); // 当前展示图片标识
const imageWidth = ref(); // 图片宽度
const imageHeight = ref(); // 图片高度
const complete = ref(Array(props.images.length).fill(false)); // 图片是否加载完成
const emits = defineEmits(['change', 'click']);
const carouselWidth = computed(() => {
// 走马灯区域宽度
if (typeof props.width === 'number') {
return `${props.width}px`;
} else {
return props.width;
}
});
const carouselHeight = computed(() => {
// 走马灯区域高度
if (typeof props.height === 'number') {
return `${props.height}px`;
} else {
return props.height;
}
});
const imageAmount = computed(() => {
// 轮播图片数量
return props.images.length;
});
const verticalSlide = computed(() => {
// 是否垂直轮播
return ['left', 'right'].includes(props.dotPosition);
});
const moveUnitDistance = computed(() => {
// 每次移动的单位距离
if (verticalSlide.value) {
return imageHeight.value;
} else {
return imageWidth.value;
}
});
const carouselStyle = computed(() => {
if (props.effect === 'slide') {
return {
transform: (verticalSlide.value ? 'translateY' : 'translateX') + `(${-offset.value}px)`
};
} else {
return {};
}
});
watch(
() => [
verticalSlide.value,
props.effect,
props.images,
props.autoplay,
props.interval,
props.fadeDuration,
props.fadeFunction,
complete.value[0]
],
() => {
initCarousel();
},
{
deep: true,
flush: 'post'
}
);
watch(activeSwitcher, (to) => {
emits('change', to);
});
useEventListener(document, 'visibilitychange', visibilityChange);
useResizeObserver(carouselRef, () => {
getImageSize();
initCarousel();
});
function initCarousel() {
if (slideTimer.value) {
cancelRaf(slideTimer.value);
}
if (moveEffectRaf.value) {
cancelAnimationFrame(moveEffectRaf.value);
}
switchPrevent.value = false;
if (props.effect === 'slide') {
offset.value = (activeSwitcher.value - 1) * moveUnitDistance.value;
}
onStart();
}
function onComplete(index: number) {
// 图片加载完成
complete.value[index] = true;
}
function getImageSize() {
// 获取每张图片大小
imageWidth.value = carouselRef.value.offsetWidth;
imageHeight.value = carouselRef.value.offsetHeight;
}
function onKeyboard(e: KeyboardEvent) {
if (imageAmount.value > 1) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
onLeftArrow();
}
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
onRightArrow();
}
}
}
// 当用户导航到新页面、切换标签页、关闭标签页、最小化或关闭浏览器,或者在移动设备上从浏览器切换到不同的应用程序时,暂停切换
function visibilityChange() {
console.log('visibilityState', document.visibilityState);
const visibility = document.visibilityState;
if (visibility === 'hidden') {
if (slideTimer.value) {
cancelRaf(slideTimer.value);
}
// hidden
offset.value = originNumber.value + distance.value;
switchPrevent.value = false;
} else {
// visible
onStart();
}
}
function onStart() {
if (props.autoplay && imageAmount.value > 1 && complete.value[0]) {
// 超过一条时滑动
stopCarousel.value = false;
autoSlide(); // 自动滑动轮播
console.log('Carousel Start');
}
}
function onStop() {
if (slideTimer.value) {
cancelRaf(slideTimer.value);
}
stopCarousel.value = true;
console.log('Carousel Stop');
}
function autoSlide() {
if (!stopCarousel.value) {
if (slideTimer.value) {
cancelRaf(slideTimer.value);
}
slideTimer.value = rafTimeout(() => {
switchPrevent.value = true; // 禁用导航切换
if (props.effect === 'slide') {
const target = (offset.value % (imageAmount.value * moveUnitDistance.value)) + moveUnitDistance.value;
moveLeft(target);
activeSwitcher.value = (activeSwitcher.value % imageAmount.value) + 1;
} else {
// fade
moveFade('left');
}
}, props.interval);
}
}
function onLeftArrow() {
if (!switchPrevent.value) {
switchPrevent.value = true;
if (slideTimer.value) {
cancelRaf(slideTimer.value);
}
if (props.effect === 'slide') {
const target = ((activeSwitcher.value + imageAmount.value - 2) % imageAmount.value) * moveUnitDistance.value;
moveRight(target);
activeSwitcher.value = activeSwitcher.value - 1 > 0 ? activeSwitcher.value - 1 : imageAmount.value;
} else {
// fade
moveFade('right');
}
}
}
function onRightArrow() {
if (!switchPrevent.value) {
switchPrevent.value = true;
if (slideTimer.value) {
cancelRaf(slideTimer.value);
}
if (props.effect === 'slide') {
const target = activeSwitcher.value * moveUnitDistance.value;
moveLeft(target);
activeSwitcher.value = (activeSwitcher.value % imageAmount.value) + 1;
} else {
// fade
moveFade('left');
}
}
}
const baseNumber = ref(0);
const originNumber = ref(0); // 初始位置
const distance = ref(0); // 滑动距离
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const cubicBezierNumber = useTransition(baseNumber, {
duration: props.slideDuration, // 过渡动画时长
transition: props.slideFunction // 过渡动画函数
});
function moveFade(direction: 'left' | 'right' | 'switch', n?: number) {
if (direction === 'left') {
activeSwitcher.value = (activeSwitcher.value % imageAmount.value) + 1;
} else if (direction === 'right') {
activeSwitcher.value = activeSwitcher.value - 1 > 0 ? activeSwitcher.value - 1 : imageAmount.value;
} else {
activeSwitcher.value = n as number;
}
rafTimeout(() => {
switchPrevent.value = false;
if (props.autoplay) {
autoSlide();
}
}, props.fadeDuration);
}
function toggleNumber(target: number) {
targetPosition.value = target;
baseNumber.value = baseNumber.value ? 0 : 1;
originNumber.value = offset.value; // 初始位置
distance.value = target - originNumber.value; // 总距离
}
function moveEffect() {
// 滑动效果函数
if (baseNumber.value) {
offset.value = originNumber.value + distance.value * cubicBezierNumber.value;
} else {
offset.value = originNumber.value + distance.value * (1 - cubicBezierNumber.value);
}
}
function moveLeftEffect() {
if (offset.value >= targetPosition.value) {
switchPrevent.value = false;
if (props.autoplay) {
autoSlide(); // 自动间隔切换下一张
}
} else {
moveEffect();
moveEffectRaf.value = requestAnimationFrame(moveLeftEffect);
}
}
function moveLeft(target: number) {
// 箭头切换或跳转切换,向左滑动效果
if (offset.value === imageAmount.value * moveUnitDistance.value) {
// 最后一张时重置left
offset.value = 0;
}
toggleNumber(target);
moveEffectRaf.value = requestAnimationFrame(moveLeftEffect);
}
function moveRightEffect() {
if (offset.value <= targetPosition.value) {
switchPrevent.value = false;
if (props.autoplay) {
autoSlide();
}
} else {
moveEffect();
moveEffectRaf.value = requestAnimationFrame(moveRightEffect);
}
}
function moveRight(target: number) {
// 箭头切换或跳转切换,向右滑动效果
if (offset.value === 0) {
// 第一张时重置left
offset.value = imageAmount.value * moveUnitDistance.value;
}
toggleNumber(target);
moveEffectRaf.value = requestAnimationFrame(moveRightEffect);
}
function onSwitch(n: number) {
// 分页切换图片
if (!switchPrevent.value && activeSwitcher.value !== n) {
switchPrevent.value = true;
if (slideTimer.value) {
cancelRaf(slideTimer.value);
}
if (n < activeSwitcher.value) {
// 往右滑动
if (props.effect === 'slide') {
const target = (n - 1) * moveUnitDistance.value;
moveRight(target);
activeSwitcher.value = n;
} else {
// fade
moveFade('switch', n);
}
}
if (n > activeSwitcher.value) {
// 往左滑动
if (props.effect === 'slide') {
const target = (n - 1) * moveUnitDistance.value;
moveLeft(target);
activeSwitcher.value = n;
} else {
// fade
moveFade('switch', n);
}
}
}
}
function onMouseEnter(n: number) {
onSwitch(n);
}
function clickImage(image: Image) {
emits('click', image);
}
function to(n: number): void {
if (n >= 1 && n <= imageAmount.value) {
onSwitch(n);
}
}
function prev(): void {
onLeftArrow();
}
function next(): void {
onRightArrow();
}
function getCurrentIndex(): number {
return activeSwitcher.value;
}
defineExpose({
to,
prev,
next,
getCurrentIndex
});
</script>
<template>
<div
ref="carouselRef"
class="m-carousel"
:class="{ 'carousel-vertical': verticalSlide, 'carousel-fade': effect === 'fade' }"
:style="`--arrow-color: ${arrowColor}; --dot-size: ${dotSize}px; --dot-color: ${dotColor}; --fade-duration: ${props.fadeDuration}ms; --fade-function: ${props.fadeFunction}; width: ${carouselWidth}; height: ${carouselHeight};`"
@mouseenter="autoplay && pauseOnMouseEnter ? onStop() : () => false"
@mouseleave="autoplay && pauseOnMouseEnter ? onStart() : () => false"
>
<div class="m-carousel-flex" :style="carouselStyle">
<div
class="m-image"
:class="{ 'image-fade-active': effect === 'fade' && activeSwitcher === index + 1 }"
@click="clickImage(image)"
v-for="(image, index) in images"
:key="index"
>
<Spin :spinning="!complete[index]" indicator="dynamic-circle" v-bind="spinProps">
<img
@load="onComplete(index)"
:src="image.src"
:key="image.src"
:alt="image.title"
class="u-image"
:style="`width: ${imageWidth}px; height: ${imageHeight}px;`"
/>
</Spin>
</div>
<div class="m-image" @click="clickImage(images[0])" v-if="imageAmount && effect === 'slide'">
<Spin :spinning="!complete[0]" indicator="dynamic-circle" v-bind="spinProps">
<img
@load="onComplete(0)"
:src="images[0].src"
:key="images[0].src"
:alt="images[0].title"
class="u-image"
:style="`width: ${imageWidth}px; height: ${imageHeight}px;`"
/>
</Spin>
</div>
</div>
<template v-if="showArrow">
<svg
tabindex="0"
class="arrow-left"
:style="`width: ${arrowSize}px; height: ${arrowSize}px;`"
@click="onLeftArrow"
@keydown.prevent="onKeyboard"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
>
<path
d="M10.26 3.2a.75.75 0 0 1 .04 1.06L6.773 8l3.527 3.74a.75.75 0 1 1-1.1 1.02l-4-4.25a.75.75 0 0 1 0-1.02l4-4.25a.75.75 0 0 1 1.06-.04z"
></path>
</svg>
<svg
tabindex="0"
class="arrow-right"
:style="`width: ${arrowSize}px; height: ${arrowSize}px;`"
@click="onRightArrow"
@keydown.prevent="onKeyboard"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
>
<path
d="M5.74 3.2a.75.75 0 0 0-.04 1.06L9.227 8L5.7 11.74a.75.75 0 1 0 1.1 1.02l4-4.25a.75.75 0 0 0 0-1.02l-4-4.25a.75.75 0 0 0-1.06-.04z"
></path>
</svg>
</template>
<div class="m-switch" :class="`switch-${dotPosition}`" v-if="dots">
<div
tabindex="0"
class="u-dot"
:style="[dotStyle, activeSwitcher === n ? { backgroundColor: dotActiveColor, ...dotActiveStyle } : {}]"
v-for="n in imageAmount"
:key="n"
@click="dotsTrigger === 'click' ? onSwitch(n) : () => false"
@mouseenter="dotsTrigger === 'hover' ? onMouseEnter(n) : () => false"
@keydown.prevent="onKeyboard"
></div>
</div>
</div>
</template>
<style lang="less" scoped>
.m-carousel {
display: inline-block;
margin: 0 auto;
position: relative;
overflow: hidden;
.m-carousel-flex {
display: flex;
width: 100%;
height: 100%;
// will-change: transform;
.m-image {
// 指定了 flex 元素的收缩规则。flex 元素仅在默认宽度之和大于容器的时候才会发生收缩,其收缩的大小是依据 flex-shrink 的值
flex-shrink: 0; // 默认为 1为 0 时不缩小
display: inline-block;
cursor: pointer;
.u-image {
display: inline-block;
object-fit: cover;
vertical-align: bottom; // 消除img标签底部的5px
}
}
}
&:hover {
.arrow-left {
opacity: 0.7;
pointer-events: auto;
}
.arrow-right {
opacity: 0.7;
pointer-events: auto;
}
}
.arrow-left {
position: absolute;
left: 6px;
top: 50%;
transform: translateY(-50%);
color: var(--arrow-color);
fill: currentColor;
cursor: pointer;
opacity: 0;
pointer-events: none;
outline: none;
transition: opacity 0.3s;
&:hover {
opacity: 1;
}
}
.arrow-right {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
color: var(--arrow-color);
fill: currentColor;
cursor: pointer;
opacity: 0;
pointer-events: none;
outline: none;
transition: opacity 0.3s;
&:hover {
opacity: 1;
}
}
.m-switch {
display: flex;
justify-content: center;
gap: 8px;
position: absolute;
z-index: 9;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
height: auto;
.u-dot {
// flex: 0 1 auto;
width: var(--dot-size);
height: var(--dot-size);
border-radius: var(--dot-size);
background-color: var(--dot-color);
cursor: pointer;
outline: none;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
}
.switch-top {
top: 12px;
bottom: auto;
}
.switch-left {
left: 12px;
right: auto;
top: 50%;
bottom: auto;
transform: translateY(-50%);
flex-direction: column;
}
.switch-right {
right: 12px;
left: auto;
top: 50%;
bottom: auto;
transform: translateY(-50%);
flex-direction: column;
}
}
.carousel-vertical {
.m-carousel-flex {
flex-direction: column;
}
.arrow-left {
top: 6px;
left: 50%;
transform: translateX(-50%) rotate(90deg);
}
.arrow-right {
top: auto;
bottom: 6px;
left: 50%;
transform: translateX(-50%) rotate(90deg);
}
}
.carousel-fade {
.m-image {
position: absolute;
opacity: 0;
pointer-events: none;
transition-property: opacity;
transition-duration: var(--fade-duration);
transition-timing-function: var(--fade-function);
}
.image-fade-active {
opacity: 1;
pointer-events: auto;
}
}
</style>

View File

@@ -1,188 +0,0 @@
<script setup lang="ts">
import {ref, watchEffect} from 'vue';
interface Option {
label?: string // 选项名
value?: string | number // 选项值
disabled?: boolean // 是否禁用选项,默认 false
children?: Option[] // 选项 children 数组
[propName: string]: any // 添加一个字符串索引签名,用于包含带有任意数量的其他属性
}
interface Props {
options?: Option[] // 可选项数据源
label?: string // 下拉字典项的文本字段名
value?: string // 下拉字典项的值字段名
children?: string // 下拉字典项的后代字段名
placeholder?: string | string[] // 三级下拉各自占位文本
changeOnSelect?: boolean // 当此项为 true 时点选每级菜单选项值v-model都会发生变化否则只有选择第三级选项后选项值才会变化
gap?: number // 级联下拉框相互间隙宽度,单位 px
width?: 'auto' | number | number[] // 三级下拉各自宽度
height?: number // 下拉框高度
disabled?: boolean | boolean[] // 三级各自是否禁用
allowClear?: boolean // 是否支持清除
search?: boolean // 是否支持搜索,使用搜索时请设置 width
/*
根据输入项进行筛选,默认为 true 时,筛选每个选项的文本字段 label 是否包含输入项,包含返回 true反之返回 false
当其为函数 Function 时,接受 inputValue option 两个参数,当 option 符合筛选条件时,应返回 true反之则返回 false
*/
filter?: any | true // 过滤条件函数,仅当支持搜索时生效
maxDisplay?: number // 下拉面板最多能展示的下拉项数,超过后滚动显示
modelValue?: number[] | string[] //v-model级联选中项
}
const props = withDefaults(defineProps<Props>(), {
options: () => [],
label: 'label',
value: 'value',
children: 'children',
placeholder: '请选择',
changeOnSelect: false,
gap: 8,
width: 'auto',
height: 32,
disabled: false,
allowClear: false,
search: false,
filter: true,
maxDisplay: 6,
modelValue: () => []
});
const values = ref<(string | number)[]>([]); // 级联value值数组
const labels = ref<string[]>([]); // 级联label文本数组
const firstOptions = ref<Option[]>([]);
const secondOptions = ref<Option[]>([]);
const thirdOptions = ref<Option[]>([]);
const emits = defineEmits(['update:modelValue', 'change']);
watchEffect(() => {
firstOptions.value = [...props.options];
});
watchEffect(() => {
values.value = [...props.modelValue];
});
watchEffect(() => {
initCascader(values.value);
initLabels(values.value);
});
function findChildren(options: Option[], index: number): Option[] {
const len = options.length;
for (let i = 0; i < len; i++) {
if (options[i][props.value] === values.value[index]) {
return options[i][props.children] || [];
}
}
return [];
}
function initCascader(values: (string | number)[]) {
// 获取二级/三级下拉项
secondOptions.value = findChildren(firstOptions.value, 0);
thirdOptions.value = [];
if (values.length > 1) {
thirdOptions.value = findChildren(secondOptions.value, 1);
}
}
function findLabel(options: Option[], index: number): any {
const len = options.length;
for (let i = 0; i < len; i++) {
if (options[i][props.value] === values.value[index]) {
return options[i][props.label];
}
}
return values.value[index];
}
function initLabels(values: (string | number)[]) {
labels.value[0] = findLabel(firstOptions.value, 0);
if (values.length > 1) {
labels.value[1] = findLabel(secondOptions.value, 1);
}
if (values.length > 2) {
labels.value[2] = findLabel(thirdOptions.value, 2);
}
}
function onFirstChange(value: string | number, label: string) {
// 一级下拉回调
if (props.changeOnSelect) {
emits('update:modelValue', [value]);
emits('change', [value], [label]);
} else {
values.value = [value];
labels.value = [label];
}
}
function onSecondChange(value: string | number, label: string) {
// 二级下拉回调
if (props.changeOnSelect) {
emits('update:modelValue', [values.value[0], value]);
emits('change', [values.value[0], value], [labels.value[0], label]);
} else {
values.value = [values.value[0], value];
labels.value = [labels.value[0], label];
}
}
function onThirdChange(value: string | number, label: string) {
// 三级下拉回调
emits('update:modelValue', [...values.value.slice(0, 2), value]);
emits('change', [...values.value.slice(0, 2), value], [...labels.value.slice(0, 2), label]);
}
</script>
<template>
<div class="m-cascader" :style="`height: ${height}px; gap: ${gap}px;`">
<Select
:options="firstOptions"
:label="label"
:value="value"
:placeholder="Array.isArray(placeholder) ? placeholder[0] : placeholder"
:disabled="Array.isArray(disabled) ? disabled[0] : disabled"
:allow-clear="allowClear"
:search="search"
:filter="filter"
:width="Array.isArray(width) ? width[0] : width"
:height="height"
:max-display="maxDisplay"
v-model="values[0]"
@change="onFirstChange"
/>
<Select
:options="secondOptions"
:label="label"
:value="value"
:placeholder="Array.isArray(placeholder) ? placeholder[1] : placeholder"
:disabled="Array.isArray(disabled) ? disabled[1] : disabled"
:allow-clear="allowClear"
:search="search"
:filter="filter"
:width="Array.isArray(width) ? width[1] : width"
:height="height"
:max-display="maxDisplay"
v-model="values[1]"
@change="onSecondChange"
/>
<Select
:options="thirdOptions"
:label="label"
:value="value"
:placeholder="Array.isArray(placeholder) ? placeholder[2] : placeholder"
:disabled="Array.isArray(disabled) ? disabled[2] : disabled"
:allow-clear="allowClear"
:search="search"
:filter="filter"
:width="Array.isArray(width) ? width[2] : width"
:height="height"
:max-display="maxDisplay"
v-model="values[2]"
@change="onThirdChange"
/>
</div>
</template>
<style lang="less" scoped>
.m-cascader {
display: inline-flex;
}
</style>

View File

@@ -1,249 +0,0 @@
<script setup lang="ts">
import {computed, ref, watchEffect} from 'vue';
interface Option {
label: string // 选项名
value: string | number // 选项值
disabled?: boolean // 是否禁用选项
}
interface Props {
options?: Option[] // 复选框选项数据
disabled?: boolean // 是否禁用
vertical?: boolean // 是否垂直排列
value?: (string | number)[] // (v-model) 当前选中的值,配合 options 使用
gap?: number | number[] // 多个复选框之间的间距;垂直排列时为垂直间距,单位 px数组间距用于水平排列折行时[水平间距, 垂直间距]
width?: string | number // 复选区域最大宽度,超出后折行,单位 px
height?: string | number // 复选区域最大高度,超出后滚动,单位 px
indeterminate?: boolean // 全选时的样式控制
checked?: boolean // (v-model) 当前是否选中
}
const props = withDefaults(defineProps<Props>(), {
options: () => [],
disabled: false,
vertical: false,
value: () => [],
gap: 8,
width: 'auto',
height: 'auto',
indeterminate: false,
checked: false
});
const checkboxChecked = ref<boolean>();
const optionsCheckedValue = ref<any[]>([]);
const emits = defineEmits(['update:value', 'update:checked', 'change']);
const optionsAmount = computed(() => {
// 选项总数
return props.options.length;
});
const maxWidth = computed(() => {
// 复选区域最大宽度
if (typeof props.width === 'number') {
return `${props.width}px`;
} else {
return props.width;
}
});
const maxHeight = computed(() => {
// 复选区域最大高度
if (typeof props.height === 'number') {
return `${props.height}px`;
} else {
return props.height;
}
});
const gapValue = computed(() => {
if (!props.vertical && Array.isArray(props.gap)) {
return `${props.gap[1]}px ${props.gap[0]}px`;
}
return `${props.gap}px`;
});
watchEffect(() => {
checkboxChecked.value = props.checked;
});
watchEffect(() => {
optionsCheckedValue.value = props.value;
});
function checkDisabled(disabled: boolean | undefined) {
if (disabled === undefined) {
return props.disabled;
} else {
return disabled;
}
}
function onClick(value: string | number) {
if (optionsCheckedValue.value.includes(value)) {
// 已选中
const newVal = optionsCheckedValue.value.filter((target) => target !== value);
optionsCheckedValue.value = newVal;
emits('update:value', newVal);
emits('change', newVal);
} else {
// 未选中
const newVal = [...optionsCheckedValue.value, value];
optionsCheckedValue.value = newVal;
emits('update:value', newVal);
emits('change', newVal);
}
}
function onChecked() {
checkboxChecked.value = !checkboxChecked.value;
emits('update:checked', checkboxChecked.value);
emits('change', checkboxChecked.value);
}
</script>
<template>
<div
class="m-checkbox"
:class="{ 'checkbox-vertical': vertical }"
:style="`--checkbox-gap: ${gapValue}; --checkbox-max-width: ${maxWidth}; --checkbox-max-height: ${maxHeight};`"
>
<template v-if="optionsAmount">
<div class="m-checkbox-wrap" v-for="(option, index) in options" :key="index">
<div
class="m-checkbox-box"
:class="{ 'checkbox-disabled': checkDisabled(option.disabled) }"
@click="checkDisabled(option.disabled) ? () => false : onClick(option.value)"
>
<span class="checkbox-box" :class="{ 'checkbox-checked': optionsCheckedValue.includes(option.value) }"></span>
<span class="checkbox-label">
<slot :label="option.label">{{ option.label }}</slot>
</span>
</div>
</div>
</template>
<div v-else class="m-checkbox-wrap">
<div
class="m-checkbox-box"
:class="{ 'checkbox-disabled': disabled }"
@click="disabled ? () => false : onChecked()"
>
<span
class="checkbox-box"
:class="{
'checkbox-checked': checkboxChecked && !indeterminate,
'checkbox-indeterminate': indeterminate
}"
></span>
<span class="checkbox-label">
<slot></slot>
</span>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.m-checkbox {
display: inline-flex;
flex-wrap: wrap;
gap: var(--checkbox-gap);
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
line-height: 1;
max-width: var(--checkbox-max-width);
max-height: var(--checkbox-max-height);
overflow: auto;
.m-checkbox-wrap {
.m-checkbox-box {
display: inline-flex;
align-items: flex-start;
cursor: pointer;
&:not(.checkbox-disabled):hover {
.checkbox-box {
border-color: #40a9ff;
}
}
.checkbox-box {
/*
如果所有项目的flex-shrink属性都为1当空间不足时都将等比例缩小
如果一个项目的flex-shrink属性为0其他项目都为1则空间不足时前者不缩小。
*/
flex-shrink: 0; // 默认 1.即空间不足时,项目将缩小
position: relative;
top: 3px;
width: 16px;
height: 16px;
background: transparent;
border: 1px solid #d9d9d9;
border-radius: 4px;
transition: all 0.3s;
&::after {
box-sizing: border-box;
position: absolute;
top: 50%;
left: 21.5%;
display: table;
width: 5.7142857142857135px;
height: 9.142857142857142px;
border: 2px solid #fff;
border-top: 0;
border-left: 0;
transform: rotate(45deg) scale(0) translate(-50%, -50%);
opacity: 0;
content: '';
transition: all 0.1s cubic-bezier(0.71, -0.46, 0.88, 0.6),
opacity 0.1s;
}
}
.checkbox-checked {
background-color: #40a9ff;
border-color: #40a9ff;
&::after {
opacity: 1;
transform: rotate(45deg) scale(1) translate(-50%, -50%);
transition: all 0.2s cubic-bezier(0.12, 0.4, 0.29, 1.46) 0.1s;
}
}
.checkbox-indeterminate {
&::after {
top: 50%;
left: 50%;
width: 8px;
height: 8px;
background-color: #40a9ff;
border: 0;
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
}
.checkbox-label {
word-break: break-all;
padding: 0 8px;
line-height: 1.5714285714285714;
}
}
.checkbox-disabled {
color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
.checkbox-box {
border-color: #d9d9d9;
background-color: rgba(0, 0, 0, 0.04);
&::after {
border-color: rgba(0, 0, 0, 0.25);
animation-name: none;
}
}
}
}
}
.checkbox-vertical {
flex-direction: column;
flex-wrap: nowrap;
}
</style>

View File

@@ -1,394 +0,0 @@
<script setup lang="ts">
import type {CSSProperties, Slot} from 'vue';
import {ref} from 'vue';
import Button from '../Button/Button.vue';
import {rafTimeout} from '../Utils';
interface Collapse {
key?: string | number // 对应 activeKey如果没有传入 key 属性,则默认使用数据索引 (0,1,2...) 绑定
header?: string // 面板标题 string | slot
content?: string // 面板内容 string | slot
disabled?: boolean // 是否禁用展开,默认 false
showArrow?: boolean // 是否展示箭头,默认 true
extra?: string // 面板标题右侧的额外内容 string | slot
}
interface Props {
collapseData?: Collapse[] // 折叠面板数据,可使用 slot 替换指定 key 的 header、content、arrow、extra、lang
activeKey?: string[] | string | number[] | number | null // (v-model) 当前激活 tab 面板的 key传入 string | number 类型时,即为手风琴模式
disabled?: boolean // 是否禁用,优先级低于 Collapse 的 disabled
collapseStyle?: CSSProperties // 设置面板的样式
bordered?: boolean // 带边框风格的折叠面板
copyable?: boolean // 是否可复制面板内容
copyProps?: object // 复制按钮属性配置,参考 Button Props
lang?: string // 面板右上角固定内容,例如标识 language string | slot
itemStyle?: CSSProperties // 设置面板容器的样式
headerStyle?: CSSProperties // 设置面板标题的样式
contentStyle?: CSSProperties // 设置面板内容的样式
arrow?: Slot // 自定义箭头切换图标 slot
showArrow?: boolean // 是否展示箭头,优先级低于 Collapse 的 showArrow
arrowPlacement?: 'left' | 'right' // 箭头位置
arrowStyle?: CSSProperties // 设置面板箭头的样式
extra?: string // 面板标题右侧的额外内容 string | slot
ghost?: boolean // 使折叠面板透明且无边框
}
const props = withDefaults(defineProps<Props>(), {
collapseData: () => [],
activeKey: null,
disabled: false,
collapseStyle: () => ({}),
bordered: true,
copyable: false,
copyProps: () => ({}),
lang: undefined,
itemStyle: () => ({}),
headerStyle: () => ({}),
contentStyle: () => ({}),
arrow: undefined,
showArrow: true,
arrowPlacement: 'left',
arrowStyle: () => ({}),
extra: undefined,
ghost: false
});
const contentRef = ref(); // 面板内容的模板引用
const copyTxt = ref('Copy');
const emits = defineEmits(['update:activeKey', 'change']);
function setProperties(el: Element) {
;(el as HTMLElement).style.height =
(el.lastElementChild as HTMLElement).offsetHeight + (props.bordered && !props.ghost ? 1 : 0) + 'px'
;(el as HTMLElement).style.opacity = '1';
}
function removeProperties(el: Element) {
;(el as HTMLElement).style.removeProperty('height')
;(el as HTMLElement).style.removeProperty('opacity');
}
function emitValue(value: any) {
emits('update:activeKey', value);
emits('change', value);
}
function onClick(key: number | string) {
if (activeCheck(key)) {
if (Array.isArray(props.activeKey)) {
const res = (props.activeKey as any[]).filter((actKey: number | string) => actKey !== key);
emitValue(res);
} else {
emitValue(null);
}
} else {
if (Array.isArray(props.activeKey)) {
emitValue([...props.activeKey, key]);
} else {
emitValue(key);
}
}
}
function activeCheck(key: number | string): boolean {
if (Array.isArray(props.activeKey)) {
return (props.activeKey as any[]).includes(key);
} else {
return props.activeKey === key;
}
}
function onCopy(index: number) {
navigator.clipboard.writeText(contentRef.value[index].innerText || '').then(
() => {
/* clipboard successfully set */
copyTxt.value = 'Copied';
rafTimeout(() => {
copyTxt.value = 'Copy';
}, 3000);
},
(err) => {
/* clipboard write failed */
copyTxt.value = err;
}
);
}
</script>
<template>
<div
class="m-collapse"
:class="{
'collapse-borderless': !bordered,
'collapse-arrow-right': arrowPlacement === 'right',
'collapse-ghost': ghost
}"
:style="collapseStyle"
>
<div
class="m-collapse-item"
:class="{ 'collapse-item-disabled': data.disabled === undefined ? disabled : data.disabled }"
:style="itemStyle"
v-for="(data, index) in collapseData"
:key="index"
>
<div
tabindex="0"
class="m-collapse-header"
:class="{ 'collapse-header-no-arrow': data.showArrow !== undefined ? !data.showArrow : !showArrow }"
:style="headerStyle"
@click="(data.disabled === undefined ? disabled : data.disabled) ? () => false : onClick(data.key || index)"
@keydown.enter="onClick(data.key || index)"
>
<div
v-if="data.showArrow !== undefined ? data.showArrow : showArrow"
class="collapse-arrow"
:style="arrowStyle"
>
<slot name="arrow" :key="data.key || index" :active="activeCheck(data.key || index)">
<svg
class="arrow-svg"
:class="{ 'arrow-rotate': activeCheck(data.key || index) }"
focusable="false"
data-icon="right"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M765.7 486.8L314.9 134.7A7.97 7.97 0 00302 141v77.3c0 4.9 2.3 9.6 6.1 12.6l360 281.1-360 281.1c-3.9 3-6.1 7.7-6.1 12.6V883c0 6.7 7.7 10.4 12.9 6.3l450.8-352.1a31.96 31.96 0 000-50.4z"
></path>
</svg>
</slot>
</div>
<div class="collapse-header">
<slot name="header" :header="data.header" :key="data.key || index" :active="activeCheck(data.key || index)">
{{ data.header || '--' }}
</slot>
</div>
<div class="collapse-extra">
<slot name="extra" :extra="data.extra" :key="data.key || index" :active="activeCheck(data.key || index)">
{{ data.extra || extra }}
</slot>
</div>
</div>
<Transition
name="collapse"
@enter="setProperties"
@after-enter="removeProperties"
@leave="setProperties"
@after-leave="removeProperties"
>
<div
v-show="activeCheck(data.key || index)"
class="m-collapse-content"
:class="{ 'collapse-copyable': copyable }"
>
<div class="collapse-lang">
<slot name="lang" :lang="lang" :key="data.key || index" :active="activeCheck(data.key || index)">
{{ lang }}
</slot>
</div>
<Button class="collapse-copy" size="small" type="primary" @click="onCopy(index)" v-bind="copyProps">{{
copyTxt
}}
</Button>
<div ref="contentRef" class="collapse-content" :style="contentStyle">
<slot
name="content"
:content="data.content"
:key="data.key || index"
:active="activeCheck(data.key || index)"
>
{{ data.content }}
</slot>
</div>
</div>
</Transition>
</div>
</div>
</template>
<style lang="less" scoped>
.collapse-enter-active,
.collapse-leave-active {
overflow: hidden;
transition: height 0.2s cubic-bezier(0.645, 0.045, 0.355, 1),
opacity 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
}
.collapse-enter-from,
.collapse-leave-to {
height: 0 !important;
opacity: 0 !important;
}
.m-collapse {
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
background-color: rgba(0, 0, 0, 0.02);
border: 1px solid #d9d9d9;
border-bottom: 0;
border-radius: 8px;
.m-collapse-item {
border-bottom: 1px solid #d9d9d9;
&:last-child {
border-radius: 0 0 8px 8px;
.m-collapse-header,
.m-collapse-content {
border-radius: 0 0 8px 8px;
}
}
.m-collapse-header {
position: relative;
display: flex;
flex-wrap: nowrap;
align-items: flex-start;
padding: 12px 16px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
cursor: pointer;
transition: all 0.3s;
&:focus {
outline: none;
}
.collapse-arrow {
font-size: 12px;
height: 22px;
display: flex;
align-items: center;
padding-right: 12px;
.arrow-rotate {
transform: rotate(90deg);
}
:deep(svg) {
fill: currentColor;
transition: transform 0.3s;
}
}
.collapse-header {
// 元素会根据自身的宽度与高度来确定尺寸,但是会伸长并吸收 flex 容器中额外的自由空间,也会缩短自身来适应 flex 容器
flex: auto; // 相当于 flex: 1 1 auto
margin-right: auto;
display: inline-block;
}
.collapse-extra {
display: flex;
align-items: center;
:deep(svg) {
fill: currentColor;
}
}
}
.collapse-header-no-arrow {
padding-left: 12px;
}
.m-collapse-content {
position: relative;
color: rgba(0, 0, 0, 0.88);
background-color: #ffffff;
border-top: 1px solid #d9d9d9;
.collapse-lang {
position: absolute;
right: 10px;
top: 6px;
font-size: 14px;
color: rgba(0, 0, 0, 0.38);
opacity: 1;
transition: opacity 0.3s;
}
.collapse-copy {
position: absolute;
right: 8px;
top: 8px;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.collapse-content {
padding: 16px;
white-space: pre-wrap;
}
}
.collapse-copyable {
&:hover {
.collapse-lang {
opacity: 0;
pointer-events: none;
}
.collapse-copy {
opacity: 1;
pointer-events: auto;
}
}
}
}
.collapse-item-disabled {
.m-collapse-header {
color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
}
}
}
.collapse-borderless {
background-color: rgba(0, 0, 0, 0.02);
border: 0;
.m-collapse-item {
&:last-child {
border-bottom: 0;
.m-collapse-header {
border-radius: 0;
}
}
.m-collapse-content {
background-color: transparent;
border-top: 0;
}
}
}
.collapse-arrow-right {
.m-collapse-item .m-collapse-header .collapse-arrow {
order: 1; // order 属性定义项目的排列顺序。数值越小,排列越靠前,默认为 0
padding-right: 0;
padding-left: 12px;
}
}
.collapse-ghost {
background-color: transparent;
border: 0;
.m-collapse-item {
border-bottom: 0;
.m-collapse-content {
background-color: transparent;
border: 0;
}
}
}
</style>

View File

@@ -1,250 +0,0 @@
<script setup lang="ts">
import type {CSSProperties} from 'vue';
import {computed, onMounted, ref, watch} from 'vue';
import {useSlotsExist} from '../Utils';
interface Props {
title?: string // 倒计时标题 string | slot
titleStyle?: CSSProperties // 设置标题的样式
prefix?: string // 倒计时的前缀 string | slot
suffix?: string // 倒计时的后缀 string | slot
finishedText?: string // 完成后的展示文本 string | slot
future?: boolean // value 是否为未来某时刻的时间戳;为 false 表示相对剩余时间戳
format?: string // 倒计时展示格式,(Y/YYM/MMD/DDH/HHm/mm分钟s/ssSSS毫秒)
value?: number // 倒计时数值,支持设置未来某时刻的时间戳 (ms) 或 相对剩余时间 (ms)
valueStyle?: CSSProperties // 设置倒计时的样式
active?: boolean // 是否处于计时状态,仅当 future: false 时生效
}
const props = withDefaults(defineProps<Props>(), {
title: undefined,
titleStyle: () => ({}),
prefix: undefined,
suffix: undefined,
finishedText: undefined,
future: true,
format: 'HH:mm:ss',
value: 0,
valueStyle: () => ({}),
active: true
});
const futureTime = ref(0); // 未来截止时间戳
const remainingTime = ref(0); // 剩余时间戳
const rafID = ref<number | null>(null); // requestAnimationFrame 返回的请求 ID 是一个 long 类型整数值,是在回调列表里的唯一标识符
const emit = defineEmits(['finish']);
const slotsExist = useSlotsExist(['title', 'prefix', 'suffix']);
const showTitle = computed(() => {
return slotsExist.title || props.title;
});
const showPrefix = computed(() => {
return slotsExist.prefix || props.prefix;
});
const showSuffix = computed(() => {
return slotsExist.suffix || props.suffix;
});
const showType = computed(() => {
return {
showMillisecond: props.format.includes('SSS'),
showYear: props.format.includes('Y'),
showMonth: props.format.includes('M'),
showDay: props.format.includes('D'),
showHour: props.format.includes('H'),
showMinute: props.format.includes('m'),
showSecond: props.format.includes('s')
};
});
watch(
() => props.active,
(to: boolean) => {
if (!props.future) {
if (to) {
futureTime.value = remainingTime.value + Date.now();
rafID.value = requestAnimationFrame(CountDown);
} else {
if (rafID.value) {
cancelAnimationFrame(rafID.value);
}
rafID.value = null;
}
}
}
);
watch(
() => [props.value, props.future],
() => {
initCountdown();
},
{
deep: true
}
);
onMounted(() => {
initCountdown();
});
function initCountdown() {
// 只有数值类型的值且是有穷的finite才返回 true
if (Number.isFinite(props.value)) {
// 检测传入的参数是否是一个有穷数
if (props.future) {
// 未来某时刻的时间戳单位ms
if (props.value > Date.now()) {
futureTime.value = props.value;
} else {
finish();
}
} else {
// 相对剩余时间,单位 ms
if (props.value > 0) {
futureTime.value = props.value + Date.now();
} else {
finish();
}
}
remainingTime.value = futureTime.value - Date.now();
if (props.future || (!props.future && props.active)) {
if (rafID.value) {
cancelAnimationFrame(rafID.value);
}
rafID.value = requestAnimationFrame(CountDown);
}
} else {
remainingTime.value = 0;
}
}
function finish() {
remainingTime.value = 0;
emit('finish');
}
function CountDown() {
if (futureTime.value > Date.now()) {
remainingTime.value = futureTime.value - Date.now();
rafID.value = requestAnimationFrame(CountDown);
} else {
finish();
}
}
// 前置补 0
function padZero(value: number, targetLength: number = 2): string {
// 左侧补零函数
return String(value).padStart(targetLength, '0');
}
function timeFormat(time: number): string {
let showTime = props.format;
if (showType.value.showMillisecond) {
var millisecond = time % 1000;
showTime = showTime.replace('SSS', padZero(millisecond, 3));
}
time = Math.floor(time / 1000); // 将时间转为 s 为单位
let Y: number, M: number, D: number, H: number, m: number, s: number;
if (showType.value.showYear) {
Y = Math.floor(time / (60 * 60 * 24 * 30 * 12));
showTime = showTime.includes('YY') ? showTime.replace('YY', padZero(Y)) : showTime.replace('Y', String(Y));
} else {
Y = 0;
}
if (showType.value.showMonth) {
time = time - Y * 60 * 60 * 24 * 30 * 12;
M = Math.floor(time / (60 * 60 * 24 * 30));
showTime = showTime.includes('MM') ? showTime.replace('MM', padZero(M)) : showTime.replace('M', String(M));
} else {
M = 0;
}
if (showType.value.showDay) {
time = time - M * 60 * 60 * 24 * 30;
D = Math.floor(time / (60 * 60 * 24));
showTime = showTime.includes('DD') ? showTime.replace('DD', padZero(D)) : showTime.replace('D', String(D));
} else {
D = 0;
}
if (showType.value.showHour) {
time = time - D * 60 * 60 * 24;
H = Math.floor(time / (60 * 60));
showTime = showTime.includes('HH') ? showTime.replace('HH', padZero(H)) : showTime.replace('H', String(H));
} else {
H = 0;
}
if (showType.value.showMinute) {
time = time - H * 60 * 60;
m = Math.floor(time / 60);
showTime = showTime.includes('mm') ? showTime.replace('mm', padZero(m)) : showTime.replace('m', String(m));
} else {
m = 0;
}
if (showType.value.showSecond) {
s = time - m * 60;
showTime = showTime.includes('ss') ? showTime.replace('ss', padZero(s)) : showTime.replace('s', String(s));
}
return showTime;
}
function resetCountdown() {
// 重置倒计时
initCountdown();
}
defineExpose({
reset: resetCountdown
});
</script>
<template>
<div class="m-countdown">
<div v-if="showTitle" class="countdown-title" :style="titleStyle">
<slot name="title">{{ props.title }}</slot>
</div>
<div class="countdown-time">
<template v-if="showPrefix">
<span class="time-prefix" v-if="showPrefix || remainingTime > 0">
<slot name="prefix">{{ prefix }}</slot>
</span>
</template>
<span v-if="finishedText && remainingTime === 0" class="time-value" :style="valueStyle">
<slot name="finish">{{ finishedText }}</slot>
</span>
<span v-else class="time-value" :style="valueStyle">
{{ timeFormat(remainingTime) }}
</span>
<template v-if="showSuffix">
<span class="time-suffix" v-if="showSuffix || remainingTime > 0">
<slot name="suffix">{{ suffix }}</slot>
</span>
</template>
</div>
</div>
</template>
<style lang="less" scoped>
.m-countdown {
display: inline-block;
line-height: 1.5714285714285714;
.countdown-title {
margin-bottom: 4px;
color: rgba(0, 0, 0, 0.45);
font-size: 14px;
}
.countdown-time {
color: rgba(0, 0, 0, 0.88);
font-size: 24px;
font-family: 'Helvetica Neue'; // 保证数字等宽显示
.time-prefix {
display: inline-block;
margin-right: 4px;
}
.time-value {
display: inline-block;
direction: ltr;
}
.time-suffix {
display: inline-block;
margin-left: 4px;
}
}
}
</style>

View File

@@ -1,124 +0,0 @@
<script setup lang="ts">
import VueDatePicker from '@vuepic/vue-datepicker';
import '@vuepic/vue-datepicker/dist/main.css';
import { computed } from 'vue';
interface Props {
width?: number // 日期选择器宽度
mode?: 'time' | 'date' | 'week' | 'month' | 'year' // 选择器模式可选时间time日期date周week月month年year
// format?: string | ((date: Date) => string) | ((dates: Date[]) => string) // 日期展示格式,(yy: 年, M: 月, d: 天, H: 时, m: 分, s: 秒, w: 周)
showTime?: boolean // 是否增加时间选择
showToday?: boolean // 是否展示”今天“按钮
// multiCalendars?: boolean // 范围选择器是否使用双日期面板
// flow?: any[] // 定义选择顺序 ("calendar" | "time" | "month" | "year" | "minutes" | "hours" | "seconds")[]
// dark?: boolean // 样式主题是否使用黑色
modelType?: 'timestamp' | 'format' // v-model 值类型,可选 timestamp: 时间戳、format: 字符串mode 为 week 或 year 时,该配置不生效
}
const props = withDefaults(defineProps<Props>(), {
width: 180,
mode: 'date',
/* format default
Single picker: 'MM/dd/yyyy HH:mm'
Range picker: 'MM/dd/yyyy HH:mm - MM/dd/yyyy HH:mm'
Month picker: 'MM/yyyy'
Time picker: 'HH:mm'
Time picker range: 'HH:mm - HH:mm'
Week picker: 'ww-yyyy'
*/
showTime: false,
showToday: false,
// multiCalendars: false,
// flow: () => [],
// dark: false,
modelType: 'format'
});
const time = computed(() => {
return props.mode === 'time';
});
const week = computed(() => {
return props.mode === 'week';
});
const month = computed(() => {
return props.mode === 'month';
});
const year = computed(() => {
return props.mode === 'year';
});
// const format = (date: Date) => {
// const day = date.getDate()
// const month = date.getMonth() + 1
// const year = date.getFullYear()
// return `${year}-${month}-${day}`
// }
</script>
<template>
<VueDatePicker
class="m-datepicker" :style="`width: ${width}px;`"
locale="zh-CN"
:month-change-on-scroll="false"
:enable-time-picker="showTime"
:time-picker="time"
:week-picker="week"
:month-picker="month"
:year-picker="year"
now-button-label="今天"
:show-now-button="showToday"
auto-apply
text-input
:model-type="modelType"
:day-names="['', '', '', '', '', '', '']"
></VueDatePicker>
</template>
<style lang="less" scoped>
.m-datepicker {
display: inline-block;
:deep(.dp__input_wrap) {
svg {
fill: currentColor;
}
}
}
.dp__theme_dark {
// dark theme
--dp-background-color: #212121;
--dp-text-color: #ffffff;
--dp-hover-color: #484848;
--dp-hover-text-color: #ffffff;
--dp-hover-icon-color: #959595;
--dp-primary-color: #005cb2;
--dp-primary-text-color: #ffffff;
--dp-secondary-color: #a9a9a9;
--dp-border-color: #2d2d2d;
--dp-menu-border-color: #2d2d2d;
--dp-border-color-hover: #aaaeb7;
--dp-disabled-color: #737373;
--dp-scroll-bar-background: #212121;
--dp-scroll-bar-color: #484848;
--dp-success-color: #00701a;
--dp-success-color-disabled: #428f59;
--dp-icon-color: #959595;
--dp-danger-color: #e53935;
--dp-highlight-color: rgba(0, 92, 178, 0.2);
}
.dp__theme_light {
// light theme
--dp-background-color: #ffffff;
--dp-text-color: #212121;
--dp-hover-color: #f3f3f3;
--dp-hover-text-color: #212121;
--dp-hover-icon-color: #959595;
--dp-primary-color: #1976d2;
--dp-primary-text-color: #f8f5f5;
--dp-secondary-color: #c0c4cc;
--dp-border-color: #ddd;
--dp-menu-border-color: #ddd;
--dp-border-color-hover: #aaaeb7;
--dp-disabled-color: #f6f6f6;
--dp-scroll-bar-background: #f3f3f3;
--dp-scroll-bar-color: #959595;
--dp-success-color: #76d275;
--dp-success-color-disabled: #a3d9b1;
--dp-icon-color: #959595;
--dp-danger-color: #ff6f60;
--dp-highlight-color: rgba(25, 118, 210, 0.1);
}
</style>

View File

@@ -1,437 +0,0 @@
<script setup lang="ts">
import type {CSSProperties} from 'vue';
import {computed, nextTick, onMounted, ref, watch} from 'vue';
import {useEventListener, useMutationObserver, useSlotsExist} from '../Utils';
interface Responsive {
xs?: number // <576px 响应式栅格
sm?: number // ≥576px 响应式栅格
md?: number // ≥768px 响应式栅格
lg?: number // ≥992px 响应式栅格
xl?: number // ≥1200px 响应式栅格
xxl?: number // ≥1600px 响应式栅格
}
interface Props {
title?: string // 描述列表的标题,显示在最顶部 string | slot
extra?: string // 描述列表的操作区域,显示在右上方 string | slot
bordered?: boolean // 是否展示边框
vertical?: boolean // 是否使用垂直描述列表
size?: 'default' | 'middle' | 'small' // 设置列表的大小
column?: number | Responsive // 一行的 DescriptionItems 数量,可以写成数值或支持响应式的对象写法 { xs: 8, sm: 16, md: 24 }
labelStyle?: CSSProperties // 自定义标签样式,优先级低于 DescriptionItems 的 labelStyle
contentStyle?: CSSProperties // 自定义内容样式,优先级低于 DescriptionItems 的 contentStyle
}
const props = withDefaults(defineProps<Props>(), {
title: undefined,
extra: undefined,
bordered: false,
vertical: false,
size: 'default',
column: () => ({xs: 1, sm: 2, md: 3}),
labelStyle: () => ({}),
contentStyle: () => ({})
});
const defaultSlotsRef = ref(); // 所有渲染的 DescriptionsItems 节点引用
const defaultSlots = ref(true); // 用于刷新 <slot></slot>
const stopObservation = ref(true); // 停止观察器
const children = ref<any[]>(); // DescriptionsItems 节点
const tdCols = ref(); // 放置 DescriptionsItems 节点的模板引用数组
const thVerticalCols = ref(); // 放置垂直列表的 DescriptionsItems 节点的 th 模板引用数组
const tdVerticalCols = ref(); // 放置垂直列表的 DescriptionsItems 节点的 td 模板引用数组
const trBorderedRows = ref(); // 放置 DescriptionsItems 节点的模板引用数组(带边框)
const thVerticalBorderedRows = ref(); // 放置垂直列表的 DescriptionsItems 节点的 th 模板引用数组(带边框)
const tdVerticalBorderedRows = ref(); // 放置垂直列表的 DescriptionsItems 节点的 td 模板引用数组(带边框)
const groupItems = ref<any[]>([]); // 处理后的 DescriptionsItems 节点数组
const viewportWidth = ref(window.innerWidth);
function getViewportWidth() {
viewportWidth.value = window.innerWidth;
}
useEventListener(window, 'resize', getViewportWidth);
const slotsExist = useSlotsExist(['title', 'extra']);
const showHeader = computed(() => {
return slotsExist.title || slotsExist.extra || props.title || props.extra;
});
const responsiveColumn = computed(() => {
if (typeof props.column === 'object') {
if (viewportWidth.value >= 1600 && props.column.xxl) {
return props.column.xxl;
}
if (viewportWidth.value >= 1200 && props.column.xl) {
return props.column.xl;
}
if (viewportWidth.value >= 992 && props.column.lg) {
return props.column.lg;
}
if (viewportWidth.value >= 768 && props.column.md) {
return props.column.md;
}
if (viewportWidth.value >= 576 && props.column.sm) {
return props.column.sm;
}
if (viewportWidth.value < 576 && props.column.xs) {
return props.column.xs;
}
return 1;
}
return props.column;
});
watch(
() => [props.bordered, props.vertical, responsiveColumn.value, props.labelStyle, props.contentStyle],
() => {
if (!stopObservation.value) {
stopObservation.value = true;
}
refreshDefaultSlots();
},
{
deep: true
}
);
// 监听 defaultSlotsRef DOM 节点数量变化,重新渲染 Descriptions
useMutationObserver(
defaultSlotsRef,
(MutationRecord: MutationRecord[]) => {
if (!stopObservation.value) {
stopObservation.value = true;
const mutation = MutationRecord.some((mutation: any) => mutation.type === 'childList');
if (mutation) {
refreshDefaultSlots();
}
}
},
{subtree: true, childList: true, attributes: true}
);
onMounted(() => {
getGroupItems();
});
async function refreshDefaultSlots() {
defaultSlots.value = !defaultSlots.value;
await nextTick();
getGroupItems();
}
// 计算当前 group 中所有 span 之和
function getTotalSpan(group: any): number {
return group.reduce((accumulator: number, currentValue: any) => accumulator + currentValue.span, 0);
}
// 根据不同 cloumn 处理 DescriptionsItems 节点
async function getGroupItems() {
children.value = Array.from(defaultSlotsRef.value.children).filter((element: any) => {
return element.className === (props.bordered ? 'descriptions-item-bordered' : 'descriptions-item');
});
if (groupItems.value.length) {
groupItems.value.splice(0); // 清空列表
await nextTick();
}
if (children.value && children.value.length) {
const len = children.value.length;
let group: any[] = [];
for (let n = 0; n < len; n++) {
const item = {
span: Math.min(children.value[n].dataset.span ?? 1, responsiveColumn.value),
element: children.value[n]
};
if (getTotalSpan(group) < responsiveColumn.value) {
// 已有 items 的 totalSpan column
item.span = Math.min(item.span, responsiveColumn.value - getTotalSpan(group));
group.push(item);
} else {
groupItems.value.push(group);
group = [item];
}
}
// 当使用水平列表且未设置 span 时等效于 span: 1但最后一行的最后一项会包含该行剩余的所有列数
if (!props.vertical && !children.value[len - 1].dataset.span && getTotalSpan(group) < responsiveColumn.value) {
const groupLen = group.length;
group[groupLen - 1].span = group[groupLen - 1].span + responsiveColumn.value - getTotalSpan(group);
}
groupItems.value.push(group);
await nextTick();
updateDescriptions();
} else {
stopObservation.value = false;
}
}
async function updateDescriptions() {
if (props.bordered) {
// 带边框列表
groupItems.value.forEach((items: any, index: number) => {
// 每一行 tr
items.forEach((item: any) => {
const itemChildren: any[] = Array.from(item.element.children);
// 创建节点副本,否则原节点将先被移除,后插入到新位置,影响后续响应式布局计算
const th = itemChildren[0];
// 动态添加节点样式
setStyle(th, props.labelStyle);
const td = itemChildren[1];
// 动态添加节点样式
setStyle(td, props.contentStyle);
// 插入节点到指定位置
if (props.vertical) {
// 垂直列表
th.colSpan = item.span;
td.colSpan = item.span;
thVerticalBorderedRows.value[index].appendChild(th);
tdVerticalBorderedRows.value[index].appendChild(td);
} else {
th.colSpan = 1;
td.colSpan = item.span * 2 - 1;
trBorderedRows.value[index].appendChild(th);
trBorderedRows.value[index].appendChild(td);
}
});
});
} else {
;(children.value as any[]).forEach((element: any, index: number) => {
const elementChildren: any[] = Array.from(element.children);
const label = elementChildren[0];
// 动态添加节点样式
setStyle(label, props.labelStyle);
const content = elementChildren[1];
// 动态添加节点样式
setStyle(content, props.contentStyle);
// 插入节点到指定位置
if (props.vertical) {
// 垂直列表
thVerticalCols.value[index].appendChild(element.firstChild);
tdVerticalCols.value[index].appendChild(element.lastChild);
} else {
tdCols.value[index].appendChild(element);
}
});
}
await nextTick();
stopObservation.value = false;
}
// 为元素添加内联样式
function setStyle(element: any, styles: any) {
if (JSON.stringify(styles) !== '{}') {
Object.keys(styles).forEach((key: string) => {
if (!element.style[key]) {
element.style[key] = styles[key];
}
});
}
}
</script>
<template>
<div class="m-descriptions" :class="`descriptions-${size}`">
<div class="m-descriptions-header" v-if="showHeader">
<div class="descriptions-title">
<slot name="title">{{ title }}</slot>
</div>
<div class="descriptions-extra">
<slot name="extra">{{ extra }}</slot>
</div>
</div>
<div v-if="!vertical" class="m-descriptions-view" :class="{ 'descriptions-bordered': bordered }">
<table>
<tbody v-if="!bordered">
<tr v-for="(items, row) in groupItems" :key="row">
<td
ref="tdCols"
class="descriptions-item-td"
:colspan="item.span"
v-for="(item, col) in items"
:key="col"
></td>
</tr>
</tbody>
<tbody v-else>
<tr ref="trBorderedRows" class="descriptions-bordered-tr" v-for="row of groupItems.length" :key="row"></tr>
</tbody>
</table>
</div>
<div v-else class="m-descriptions-view" :class="{ 'descriptions-bordered': bordered }">
<table>
<tbody v-if="!bordered">
<template v-for="(items, row) in groupItems" :key="row">
<tr>
<th class="descriptions-item-th" :colspan="item.span" v-for="(item, col) in items" :key="col">
<div ref="thVerticalCols" class="descriptions-item"></div>
</th>
</tr>
<tr>
<td class="descriptions-item-td" :colspan="item.span" v-for="(item, col) in items" :key="col">
<div ref="tdVerticalCols" class="descriptions-item"></div>
</td>
</tr>
</template>
</tbody>
<tbody v-else>
<template v-for="row in groupItems.length" :key="row">
<tr ref="thVerticalBorderedRows" class="descriptions-bordered-tr"></tr>
<tr ref="tdVerticalBorderedRows" class="descriptions-bordered-tr"></tr>
</template>
</tbody>
</table>
</div>
<div ref="defaultSlotsRef" v-show="false">
<slot v-if="defaultSlots"></slot>
<slot v-else></slot>
</div>
</div>
</template>
<style lang="less" scoped>
.m-descriptions {
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
.m-descriptions-header {
display: flex;
align-items: center;
margin-bottom: 20px;
.descriptions-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
flex: auto;
font-weight: 600;
font-size: 16px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
}
.descriptions-extra {
margin-left: auto;
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
}
}
.m-descriptions-view {
width: 100%;
border-radius: 8px;
table {
width: 100%;
table-layout: fixed;
display: table; // 可选,只为兼容 vitepress 中 .vp-doc 的样式入侵,下同
border-collapse: separate; // 可选
margin: 0; // 可选
tr {
// 可选
border: none;
background: transparent;
}
.descriptions-item-th {
padding: 0; // 可选
border: none; // 可选
padding-bottom: 16px;
vertical-align: top;
background: transparent; // 可选
}
.descriptions-item-td {
padding: 0; // 可选
border: none; // 可选
padding-bottom: 16px;
vertical-align: top;
}
.descriptions-item {
display: flex;
}
}
}
.descriptions-bordered {
border: 1px solid rgba(5, 5, 5, 0.06);
table {
table-layout: auto;
border-collapse: collapse;
display: table; // 可选
margin: 0; // 可选
.descriptions-bordered-tr {
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
&:last-child {
border-bottom: none;
}
:deep(.descriptions-label-th) {
border: none; // 可选
color: rgba(0, 0, 0, 0.88);
font-weight: normal;
font-size: 14px;
line-height: 1.5714285714285714;
text-align: start;
background-color: rgba(0, 0, 0, 0.02);
padding: 16px 24px;
border-right: 1px solid rgba(5, 5, 5, 0.06);
&:last-child {
// 消除 vertical 列表最后一个 th 的边框
border-right: none;
}
}
:deep(.descriptions-content-td) {
border: none; // 可选
display: table-cell;
flex: 1;
padding: 16px 24px;
border-right: 1px solid rgba(5, 5, 5, 0.06);
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
line-height: 1.5714285714285714;
word-break: break-word;
overflow-wrap: break-word;
&:last-child {
border-right: none;
}
}
}
}
}
}
.descriptions-middle {
.m-descriptions-view {
.descriptions-item-td {
padding-bottom: 12px !important;
}
}
.descriptions-bordered {
:deep(.descriptions-label-th) {
padding: 12px 24px !important;
}
:deep(.descriptions-content-td) {
padding: 12px 24px !important;
}
}
}
.descriptions-small {
.m-descriptions-view {
.descriptions-item-td {
padding-bottom: 8px !important;
}
}
.descriptions-bordered {
:deep(.descriptions-label-th) {
padding: 8px 16px !important;
}
:deep(.descriptions-content-td) {
padding: 8px 16px !important;
}
}
}
</style>

View File

@@ -1,493 +0,0 @@
<script setup lang="ts">
import type {CSSProperties} from 'vue';
import {computed, nextTick, onMounted, onUnmounted, ref, watch, watchEffect} from 'vue';
import Button from '../Button/Button.vue';
interface Props {
width?: string | number // 对话框宽度,单位 px
height?: string | number // 对话框高度,单位 px默认自适应内容高度
title?: string // 标题 string | slot
titleStyle?: CSSProperties // 自定义标题样式
content?: string // 内容 string | slot
contentStyle?: CSSProperties // 自定义内容样式
bodyClass?: string // 自定义 body 类名
bodyStyle?: CSSProperties // 自定义 body 样式
cancelText?: string // 取消按钮文字
cancelProps?: object // 取消按钮 props 配置,参考 Button 组件 Props
okText?: string // 确定按钮文字
okType?: 'primary' | 'danger' // 确定按钮类型
okProps?: object // 确认按钮 props 配置,优先级高于 okType参考 Button 组件 Props
footer?: boolean // 是否显示底部按钮 boolean | slot
switchFullscreen?: boolean // 是否允许切换全屏,允许后右上角会出现一个切换按钮
centered?: boolean // 是否水平垂直居中,否则固定高度水平居中
top?: string | number // 固定高度水平居中时,距顶部高度,仅当 centered: false 时生效,单位 px
transformOrigin?: 'mouse' | 'center' // 对话框动画出现的位置
confirmLoading?: boolean // 确定按钮 loading
blockScroll?: boolean // 是否在打开对话框时禁用背景滚动
keyboard?: boolean // 是否支持键盘 esc 关闭
maskClosable?: boolean // 点击蒙层是否允许关闭
maskStyle?: CSSProperties // 自定义蒙层样式
open?: boolean // 对话框是否可见
}
const props = withDefaults(defineProps<Props>(), {
width: 520,
height: 'auto',
title: undefined,
titleStyle: () => ({}),
content: undefined,
contentStyle: () => ({}),
bodyClass: undefined,
bodyStyle: () => ({}),
cancelText: '取消',
cancelProps: () => ({}),
okText: '确定',
okType: 'primary',
okProps: () => ({}),
footer: true,
switchFullscreen: false,
centered: false,
top: 100,
transformOrigin: 'mouse',
confirmLoading: false,
blockScroll: true,
keyboard: true,
maskClosable: true,
maskStyle: () => ({}),
open: false
});
const dialogRef = ref(); // dialog DOM 引用
const mousePosition = ref<{ x: number; y: number } | null>(null); // 鼠标点击位置
const dialogOpen = ref<boolean>();
const showDialogWrap = ref<boolean>();
// eslint-disable-next-line vue/no-dupe-keys
const transformOrigin = ref<string>('50% 50%');
const fullscreen = ref<boolean>(false);
const emits = defineEmits(['update:open', 'cancel', 'ok']);
const dialogWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`;
}
return props.width;
});
const dialogHeight = computed(() => {
if (typeof props.height === 'number') {
return `${props.height}px`;
}
return props.height;
});
const dialogTop = computed(() => {
if (typeof props.top === 'number') {
return `${props.top}px`;
}
return props.top;
});
const dialogStyle = computed(() => {
if (fullscreen.value) {
if (props.transformOrigin === 'mouse') {
return {
width: '100%',
transformOrigin: `${mousePosition.value?.x}px ${mousePosition.value?.y}px`
};
} else {
return {
width: '100%',
transformOrigin: transformOrigin.value
};
}
} else {
if (props.centered) {
return {
width: dialogWidth.value,
transformOrigin: transformOrigin.value
};
} else {
return {
width: dialogWidth.value,
transformOrigin: transformOrigin.value,
top: dialogTop.value
};
}
}
});
const dialogBodyStyle = computed(() => {
if (fullscreen.value) {
return {
height: '100vh',
...props.bodyStyle
};
} else {
return {
height: dialogHeight.value,
...props.bodyStyle
};
}
});
watch(
dialogOpen,
async (to) => {
if (to) {
await nextTick();
dialogRef.value.focus();
if (props.blockScroll) {
// 锁定滚动
document.documentElement.style.overflowY = 'hidden';
document.body.style.overflowY = 'hidden';
}
} else {
if (props.blockScroll) {
// 解锁滚动
document.documentElement.style.removeProperty('overflow-y');
document.body.style.removeProperty('overflow-y');
}
}
},
{
immediate: true
}
);
watchEffect(() => {
dialogOpen.value = props.open;
});
onMounted(() => {
document.addEventListener('click', getClickPosition, true); // 事件在捕获阶段执行
});
onUnmounted(() => {
document.removeEventListener('click', getClickPosition, true);
});
function getClickPosition(e: MouseEvent) {
if (!dialogOpen.value) {
mousePosition.value = {
x: e.clientX, // 相对于浏览器视口左上角的 X 坐标,不页面滚动而改变
y: e.clientY // 相对于浏览器视口左上角的 Y 坐标,不页面滚动而改变
};
}
}
async function onBeforeEnter(el: Element) {
showDialogWrap.value = true;
await nextTick();
if (props.transformOrigin === 'mouse' && mousePosition.value) {
const rect = el.getBoundingClientRect();
transformOrigin.value = `${mousePosition.value.x - rect.left}px ${mousePosition.value.y - rect.top}px`;
} else {
transformOrigin.value = '50% 50%';
}
}
function onBeforeLeave(el: Element) {
if (props.transformOrigin === 'mouse' && mousePosition.value) {
const rect = el.getBoundingClientRect();
transformOrigin.value = `${mousePosition.value.x - rect.left}px ${mousePosition.value.y - rect.top}px`;
} else {
transformOrigin.value = '50% 50%';
}
}
function onAfterLeave() {
showDialogWrap.value = false;
// 重置全屏显示
fullscreen.value = false;
}
function onFullScreen() {
fullscreen.value = !fullscreen.value;
}
function onCancel() {
dialogOpen.value = false;
emits('update:open', false);
emits('cancel');
}
function onOk() {
emits('ok');
}
</script>
<template>
<div class="m-dialog-root">
<Transition name="fade">
<div v-show="dialogOpen" class="m-dialog-mask" :style="maskStyle"></div>
</Transition>
<div
v-show="showDialogWrap"
tabindex="-1"
ref="dialogRef"
class="m-dialog-wrap"
:class="{ 'flex-centered': centered }"
@click.self="props.maskClosable ? onCancel() : () => false"
@keydown.esc="props.keyboard ? onCancel() : () => false"
>
<Transition
name="zoom"
enter-from-class="zoom-enter"
enter-active-class="zoom-enter"
enter-to-class="zoom-enter zoom-enter-active"
leave-from-class="zoom-leave"
leave-active-class="zoom-leave zoom-leave-active"
leave-to-class="zoom-leave zoom-leave-active"
@before-enter="onBeforeEnter"
@before-leave="onBeforeLeave"
@after-leave="onAfterLeave"
>
<div
v-show="dialogOpen"
class="m-dialog"
:class="{ 'dialog-with-fullscreen': fullscreen }"
:style="dialogStyle"
>
<div class="m-dialog-body-wrap" :class="bodyClass" :style="dialogBodyStyle">
<div class="dialog-header" :class="{ 'header-with-switch': switchFullscreen }" :style="titleStyle">
<slot name="title">{{ title }}</slot>
</div>
<span v-if="switchFullscreen" class="fullscreen-action" @click="onFullScreen">
<svg
v-show="!fullscreen"
focusable="false"
data-icon="fullscreen"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M290 236.4l43.9-43.9a8.01 8.01 0 00-4.7-13.6L169 160c-5.1-.6-9.5 3.7-8.9 8.9L179 329.1c.8 6.6 8.9 9.4 13.6 4.7l43.7-43.7L370 423.7c3.1 3.1 8.2 3.1 11.3 0l42.4-42.3c3.1-3.1 3.1-8.2 0-11.3L290 236.4zm352.7 187.3c3.1 3.1 8.2 3.1 11.3 0l133.7-133.6 43.7 43.7a8.01 8.01 0 0013.6-4.7L863.9 169c.6-5.1-3.7-9.5-8.9-8.9L694.8 179c-6.6.8-9.4 8.9-4.7 13.6l43.9 43.9L600.3 370a8.03 8.03 0 000 11.3l42.4 42.4zM845 694.9c-.8-6.6-8.9-9.4-13.6-4.7l-43.7 43.7L654 600.3a8.03 8.03 0 00-11.3 0l-42.4 42.3a8.03 8.03 0 000 11.3L734 787.6l-43.9 43.9a8.01 8.01 0 004.7 13.6L855 864c5.1.6 9.5-3.7 8.9-8.9L845 694.9zm-463.7-94.6a8.03 8.03 0 00-11.3 0L236.3 733.9l-43.7-43.7a8.01 8.01 0 00-13.6 4.7L160.1 855c-.6 5.1 3.7 9.5 8.9 8.9L329.2 845c6.6-.8 9.4-8.9 4.7-13.6L290 787.6 423.7 654c3.1-3.1 3.1-8.2 0-11.3l-42.4-42.4z"
></path>
</svg>
<svg
v-show="fullscreen"
focusable="false"
data-icon="fullscreen-exit"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M391 240.9c-.8-6.6-8.9-9.4-13.6-4.7l-43.7 43.7L200 146.3a8.03 8.03 0 00-11.3 0l-42.4 42.3a8.03 8.03 0 000 11.3L280 333.6l-43.9 43.9a8.01 8.01 0 004.7 13.6L401 410c5.1.6 9.5-3.7 8.9-8.9L391 240.9zm10.1 373.2L240.8 633c-6.6.8-9.4 8.9-4.7 13.6l43.9 43.9L146.3 824a8.03 8.03 0 000 11.3l42.4 42.3c3.1 3.1 8.2 3.1 11.3 0L333.7 744l43.7 43.7A8.01 8.01 0 00391 783l18.9-160.1c.6-5.1-3.7-9.4-8.8-8.8zm221.8-204.2L783.2 391c6.6-.8 9.4-8.9 4.7-13.6L744 333.6 877.7 200c3.1-3.1 3.1-8.2 0-11.3l-42.4-42.3a8.03 8.03 0 00-11.3 0L690.3 279.9l-43.7-43.7a8.01 8.01 0 00-13.6 4.7L614.1 401c-.6 5.2 3.7 9.5 8.8 8.9zM744 690.4l43.9-43.9a8.01 8.01 0 00-4.7-13.6L623 614c-5.1-.6-9.5 3.7-8.9 8.9L633 783.1c.8 6.6 8.9 9.4 13.6 4.7l43.7-43.7L824 877.7c3.1 3.1 8.2 3.1 11.3 0l42.4-42.3c3.1-3.1 3.1-8.2 0-11.3L744 690.4z"
></path>
</svg>
</span>
<span class="close-action" @click="onCancel">
<svg
width="1em"
height="1em"
fill="currentColor"
viewBox="64 64 896 896"
data-icon="close"
aria-hidden="true"
focusable="false"
>
<path
d="M563.8 512l262.5-312.9c4.4-5.2.7-13.1-6.1-13.1h-79.8c-4.7 0-9.2 2.1-12.3 5.7L511.6 449.8 295.1 191.7c-3-3.6-7.5-5.7-12.3-5.7H203c-6.8 0-10.5 7.9-6.1 13.1L459.4 512 196.9 824.9A7.95 7.95 0 0 0 203 838h79.8c4.7 0 9.2-2.1 12.3-5.7l216.5-258.1 216.5 258.1c3 3.6 7.5 5.7 12.3 5.7h79.8c6.8 0 10.5-7.9 6.1-13.1L563.8 512z"
></path>
</svg>
</span>
<div class="dialog-content" :style="contentStyle">
<slot>{{ content }}</slot>
</div>
<div v-if="footer" class="dialog-footer">
<slot name="footer">
<Button class="mr8" @click="onCancel" v-bind="cancelProps">{{ cancelText }}</Button>
<Button :type="okType" :loading="props.confirmLoading" @click="onOk" v-bind="okProps">{{
okText
}}
</Button>
</slot>
</div>
</div>
</div>
</Transition>
</div>
</div>
</template>
<style lang="less" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s linear;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.zoom-enter {
transform: none;
opacity: 0;
animation-duration: 0.3s;
animation-fill-mode: both;
animation-timing-function: cubic-bezier(0.08, 0.82, 0.17, 1);
animation-play-state: paused;
}
.zoom-enter-active {
animation-name: zoomIn;
animation-play-state: running;
@keyframes zoomIn {
0% {
transform: scale(0.2);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
}
.zoom-leave {
animation-duration: 0.2s;
animation-fill-mode: both;
animation-play-state: paused;
animation-timing-function: cubic-bezier(0.78, 0.14, 0.15, 0.86);
}
.zoom-leave-active {
animation-name: zoomOut;
animation-play-state: running;
@keyframes zoomOut {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(0.2);
opacity: 0;
}
}
}
.m-dialog-mask {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
z-index: 1000;
background: rgba(0, 0, 0, 0.45);
}
.m-dialog-wrap {
position: fixed;
inset: 0;
overflow: auto;
outline: 0;
z-index: 1010;
.m-dialog {
position: relative;
margin: 0 auto;
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
line-height: 1.5714285714285714;
width: auto;
max-width: calc(100vw - 32px);
padding-bottom: 24px;
outline: none;
.m-dialog-body-wrap {
display: flex;
flex-direction: column;
position: relative;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
padding: 20px 24px;
.dialog-header {
font-size: 16px;
color: rgba(0, 0, 0, 0.88);
font-weight: 600;
line-height: 1.5;
word-break: break-all;
background: transparent;
border-radius: 8px 8px 0 0;
margin-bottom: 8px;
max-width: calc(100% - 24px);
}
.header-with-switch {
max-width: calc(100% - 54px);
}
.fullscreen-action {
.close-action();
right: 48px;
}
.close-action {
position: absolute;
top: 20px;
right: 18px;
z-index: 1010;
font-weight: 600;
line-height: 1;
background: transparent;
border-radius: 4px;
width: 22px;
height: 22px;
cursor: pointer;
transition: background 0.2s;
display: flex;
align-items: center;
justify-content: center;
svg {
font-size: 16px;
color: rgba(0, 0, 0, 0.45);
fill: currentColor;
transition: color 0.2s;
}
&:hover {
background: rgba(0, 0, 0, 0.06);
svg {
color: rgba(0, 0, 0, 0.88);
}
}
}
.dialog-content {
flex: 1;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
word-break: break-all;
overflow: auto;
transition: all 0.25s;
}
.dialog-footer {
text-align: end;
background: transparent;
margin-top: 12px;
.mr8 {
margin-right: 8px;
}
}
}
}
.dialog-with-fullscreen {
max-width: 100%;
padding-bottom: 0;
}
}
.flex-centered {
display: flex;
justify-content: center;
align-items: center;
.m-dialog {
padding-bottom: 0;
}
}
</style>

View File

@@ -1,166 +0,0 @@
<script setup lang="ts">
import {computed} from 'vue';
import {useSlotsExist} from '../Utils';
interface Props {
orientation?: 'left' | 'center' | 'right' // 分割线标题的位置
orientationMargin?: string | number // 标题和最近 left/right 边框之间的距离,去除了分割线,同时 orientation 必须为 left 或 right
borderWidth?: number // 分割线宽度,单位 px
borderStyle?: 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' // 分割线样式
borderColor?: string // 分割线颜色
vertical?: boolean // 是否垂直分割
height?: string | number // 垂直分割线高度,仅当 vertical: true 时生效
}
const props = withDefaults(defineProps<Props>(), {
orientation: 'center',
orientationMargin: undefined,
borderWidth: 1,
borderStyle: 'solid',
borderColor: 'rgba(5, 5, 5, 0.06)',
vertical: false,
height: '0.9em'
});
const slotsExist = useSlotsExist(['default']);
const margin = computed(() => {
if (typeof props.orientationMargin === 'number') {
return `${props.orientationMargin}px`;
}
return props.orientationMargin;
});
const lineHeight = computed(() => {
if (typeof props.height === 'number') {
return `${props.height}px`;
}
return props.height;
});
const showText = computed(() => {
return slotsExist.default;
});
</script>
<template>
<div
class="m-divider"
:class="[
vertical ? 'divider-vertical' : 'divider-horizontal',
{
'divider-with-text': showText,
'divider-with-text-center': showText && orientation === 'center',
'divider-with-text-left': showText && orientation === 'left',
'divider-with-text-right': showText && orientation === 'right',
'divider-orientation-margin-left': showText && orientation === 'left' && orientationMargin !== undefined,
'divider-orientation-margin-right': showText && orientation === 'right' && orientationMargin !== undefined
}
]"
:style="`--border-width: ${borderWidth}px; --border-style: ${borderStyle}; --border-color: ${borderColor}; --margin: ${margin}; --line-height: ${lineHeight};`"
>
<span v-if="showText" class="divider-text">
<slot></slot>
</span>
</div>
</template>
<style lang="less" scoped>
.m-divider {
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
line-height: 1.5714285714285714;
border-top: var(--border-width) var(--border-style) var(--border-color);
.divider-text {
display: inline-block;
padding: 0 1em;
}
}
.divider-horizontal {
display: flex;
clear: both;
width: 100%;
min-width: 100%;
margin: 24px 0;
}
.divider-vertical {
position: relative;
top: -0.06em;
display: inline-block;
height: var(--line-height);
margin: 0 8px;
vertical-align: middle;
border-top: 0;
border-left: var(--border-width) var(--border-style) var(--border-color);
}
.divider-with-text {
display: flex;
align-items: center;
margin: 16px 0;
color: rgba(0, 0, 0, 0.88);
font-weight: 500;
font-size: 16px;
white-space: nowrap;
text-align: center;
border-top: 0 var(--border-color);
&::before,
&::after {
position: relative;
width: 50%;
border-top-width: var(--border-width);
border-top-style: var(--border-style);
border-top-color: inherit;
transform: translateY(50%);
content: '';
}
}
.divider-with-text-left {
&::before {
width: 5%;
}
&::after {
width: 95%;
}
}
.divider-with-text-right {
&::before {
width: 95%;
}
&::after {
width: 5%;
}
}
.divider-orientation-margin-left {
&::before {
width: 0;
}
&::after {
width: 100%;
}
.divider-text {
margin-left: var(--margin);
padding-left: 0;
}
}
.divider-orientation-margin-right {
&::before {
width: 100%;
}
&::after {
width: 0;
}
.divider-text {
margin-right: var(--margin);
padding-right: 0;
}
}
</style>

View File

@@ -1,386 +0,0 @@
<script setup lang="ts">
import type {CSSProperties} from 'vue';
import {computed, ref, watch, watchEffect} from 'vue';
import Scrollbar from '../Scrollbar/Scrollbar.vue';
import {useSlotsExist} from '../Utils';
interface Props {
width?: string | number // 抽屉宽度,在 placement 为 right 或 left 时使用,单位 px
height?: string | number // 抽屉高度,在 placement 为 top 或 bottom 时使用,单位 px
title?: string // 标题 string | slot
closable?: boolean // 是否显示左上角的关闭按钮
placement?: 'top' | 'right' | 'bottom' | 'left' // 抽屉的方向
headerClass?: string // 设置 Drawer 头部的类名
headerStyle?: CSSProperties // 设置 Drawer 头部的样式
scrollbarProps?: object // Scrollbar 组件属性配置,用于设置内容滚动条的样式
bodyClass?: string // 设置 Drawer 内容部分的类名
bodyStyle?: CSSProperties // 设置 Drawer 内容部分的样式
extra?: string // 抽屉右上角的操作区域 string | slot
footer?: string // 抽屉的页脚 string | slot
footerClass?: string // 设置 Drawer 页脚的类名
footerStyle?: CSSProperties // 设置 Drawer 页脚的样式
destroyOnClose?: boolean // 关闭时是否销毁 Drawer 里的子元素
zIndex?: number // 设置 Drawer 的 z-index
open?: boolean // (v-model) 抽屉是否可见
}
const props = withDefaults(defineProps<Props>(), {
width: 378,
height: 378,
title: undefined,
closable: true,
placement: 'right',
headerClass: undefined,
headerStyle: () => ({}),
scrollbarProps: () => ({}),
bodyClass: undefined,
bodyStyle: () => ({}),
extra: undefined,
footer: undefined,
footerClass: undefined,
footerStyle: () => ({}),
destroyOnClose: false,
zIndex: 1000,
open: false
});
const drawerRef = ref();
const drawerOpen = ref<boolean>();
const slotsExist = useSlotsExist(['title', 'extra', 'footer']);
const emits = defineEmits(['update:open', 'close']);
const drawerWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`;
}
return props.width;
});
const drawerHeight = computed(() => {
if (typeof props.height === 'number') {
return `${props.height}px`;
}
return props.height;
});
const drawerStyle = computed(() => {
if (['top', 'bottom'].includes(props.placement)) {
return {
zIndex: props.zIndex,
height: drawerHeight.value
};
} else {
return {
zIndex: props.zIndex,
width: drawerWidth.value
};
}
});
const showHeader = computed(() => {
return slotsExist.title || slotsExist.extra || props.title || props.extra || props.closable;
});
const showTitle = computed(() => {
return slotsExist.title || props.title;
});
const showExtra = computed(() => {
return slotsExist.extra || props.extra;
});
const showFooter = computed(() => {
return slotsExist.footer || props.footer;
});
watch(
drawerOpen,
(to) => {
if (to) {
drawerRef.value.focus();
// 锁定滚动
document.documentElement.style.overflowY = 'hidden';
document.body.style.overflowY = 'hidden';
} else {
// 解锁滚动
document.documentElement.style.removeProperty('overflow-y');
document.body.style.removeProperty('overflow-y');
}
},
{
immediate: true
}
);
watchEffect(() => {
drawerOpen.value = props.open;
});
function onBlur(e: Event) {
drawerOpen.value = false;
emits('update:open', false);
emits('close', e);
}
function onClose(e: Event) {
drawerOpen.value = false;
emits('update:open', false);
emits('close', e);
}
</script>
<template>
<div ref="drawerRef" tabindex="-1" class="m-drawer" @keydown.esc="onClose">
<Transition name="fade">
<div v-show="drawerOpen" class="m-drawer-mask" @click.self="onBlur"></div>
</Transition>
<Transition :name="`motion-${placement}`">
<div v-show="drawerOpen" class="m-drawer-wrap" :class="`drawer-${placement}`" :style="drawerStyle">
<div class="m-drawer-content">
<div v-if="!destroyOnClose" class="m-drawer-body-wrapper">
<div v-show="showHeader" class="m-drawer-header" :class="headerClass" :style="headerStyle">
<div class="m-header-title">
<svg
v-if="closable"
focusable="false"
class="svg-close"
data-icon="close"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
@click="onClose"
>
<path
d="M563.8 512l262.5-312.9c4.4-5.2.7-13.1-6.1-13.1h-79.8c-4.7 0-9.2 2.1-12.3 5.7L511.6 449.8 295.1 191.7c-3-3.6-7.5-5.7-12.3-5.7H203c-6.8 0-10.5 7.9-6.1 13.1L459.4 512 196.9 824.9A7.95 7.95 0 00203 838h79.8c4.7 0 9.2-2.1 12.3-5.7l216.5-258.1 216.5 258.1c3 3.6 7.5 5.7 12.3 5.7h79.8c6.8 0 10.5-7.9 6.1-13.1L563.8 512z"
></path>
</svg>
<div v-if="showTitle" class="header-title">
<slot name="title">{{ title }}</slot>
</div>
</div>
<div v-if="showExtra" class="header-extra">
<slot name="extra">{{ extra }}</slot>
</div>
</div>
<Scrollbar v-bind="scrollbarProps">
<div class="m-drawer-body" :class="bodyClass" :style="bodyStyle">
<slot></slot>
</div>
</Scrollbar>
<div v-if="showFooter" class="m-drawer-footer" :class="footerClass" :style="footerStyle">
<slot name="footer">{{ footer }}</slot>
</div>
</div>
<div v-if="destroyOnClose && drawerOpen" class="m-drawer-body-wrapper">
<div v-show="showHeader" class="m-drawer-header" :class="headerClass" :style="headerStyle">
<div class="m-header-title">
<svg
v-if="closable"
focusable="false"
class="svg-close"
data-icon="close"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
@click="onClose"
>
<path
d="M563.8 512l262.5-312.9c4.4-5.2.7-13.1-6.1-13.1h-79.8c-4.7 0-9.2 2.1-12.3 5.7L511.6 449.8 295.1 191.7c-3-3.6-7.5-5.7-12.3-5.7H203c-6.8 0-10.5 7.9-6.1 13.1L459.4 512 196.9 824.9A7.95 7.95 0 00203 838h79.8c4.7 0 9.2-2.1 12.3-5.7l216.5-258.1 216.5 258.1c3 3.6 7.5 5.7 12.3 5.7h79.8c6.8 0 10.5-7.9 6.1-13.1L563.8 512z"
></path>
</svg>
<div v-if="showTitle" class="header-title">
<slot name="title">{{ title }}</slot>
</div>
</div>
<div v-if="showExtra" class="header-extra">
<slot name="extra">{{ extra }}</slot>
</div>
</div>
<Scrollbar v-bind="scrollbarProps">
<div class="m-drawer-body" :class="bodyClass" :style="bodyStyle">
<slot></slot>
</div>
</Scrollbar>
<div v-if="showFooter" class="m-drawer-footer" :class="footerClass" :style="footerStyle">
<slot name="footer">{{ footer }}</slot>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<style lang="less" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.motion-top-enter-active,
.motion-top-leave-active {
transition: all 0.3s;
}
.motion-top-enter-from,
.motion-top-leave-to {
transform: translateY(-100%);
}
.motion-right-enter-active,
.motion-right-leave-active {
transition: all 0.3s;
}
.motion-right-enter-from,
.motion-right-leave-to {
transform: translateX(100%);
}
.motion-bottom-enter-active,
.motion-bottom-leave-active {
transition: all 0.3s;
}
.motion-bottom-enter-from,
.motion-bottom-leave-to {
transform: translateY(100%);
}
.motion-left-enter-active,
.motion-left-leave-active {
transition: all 0.3s;
}
.motion-left-enter-from,
.motion-left-leave-to {
transform: translateX(-100%);
}
.m-drawer {
position: fixed;
inset: 0;
z-index: 1000;
pointer-events: none;
outline: none;
.m-drawer-mask {
position: absolute;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.45);
pointer-events: auto;
}
.m-drawer-wrap {
position: absolute;
transition: all 0.3s;
.m-drawer-content {
width: 100%;
height: 100%;
overflow: auto;
background: #ffffff;
pointer-events: auto;
.m-drawer-body-wrapper {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
.m-drawer-header {
display: flex;
flex: 0;
align-items: center;
padding: 16px 24px;
font-size: 16px;
line-height: 1.5;
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
.m-header-title {
display: flex;
flex: 1;
align-items: center;
min-width: 0;
min-height: 0;
.svg-close {
display: inline-block;
margin-right: 12px;
font-size: 16px;
font-weight: 600;
color: rgba(0, 0, 0, 0.45);
fill: currentColor;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: rgba(0, 0, 0, 0.88);
}
}
.header-title {
flex: 1;
margin: 0;
color: rgba(0, 0, 0, 0.88);
font-weight: 600;
font-size: 16px;
line-height: 1.5;
}
}
.header-extra {
flex: none;
color: rgba(0, 0, 0, 0.88);
}
}
.m-drawer-body {
height: 100%;
padding: 24px;
word-break: break-all;
}
.m-drawer-footer {
flex-shrink: 0;
padding: 8px 16px;
border-top: 1px solid rgba(5, 5, 5, 0.06);
color: rgba(0, 0, 0, 0.88);
}
}
}
}
.drawer-top {
top: 0;
inset-inline: 0;
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
}
.drawer-right {
top: 0;
right: 0;
bottom: 0;
box-shadow: -6px 0 16px 0 rgba(0, 0, 0, 0.08),
-3px 0 6px -4px rgba(0, 0, 0, 0.12),
-9px 0 28px 8px rgba(0, 0, 0, 0.05);
}
.drawer-bottom {
bottom: 0;
inset-inline: 0;
box-shadow: 0 -6px 16px 0 rgba(0, 0, 0, 0.08),
0 -3px 6px -4px rgba(0, 0, 0, 0.12),
0 -9px 28px 8px rgba(0, 0, 0, 0.05);
}
.drawer-left {
top: 0;
bottom: 0;
left: 0;
box-shadow: 6px 0 16px 0 rgba(0, 0, 0, 0.08),
3px 0 6px -4px rgba(0, 0, 0, 0.12),
9px 0 28px 8px rgba(0, 0, 0, 0.05);
}
}
</style>

View File

@@ -1,148 +0,0 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { useResizeObserver } from '../Utils';
interface Props {
maxWidth?: string | number // 文本最大宽度,单位 px
tooltipMaxWidth?: string | number // 弹出提示最大宽度,单位 px默认为 maxWidth + 24
line?: number // 最大行数
expand?: boolean // 是否启用点击文本展开全部
tooltip?: boolean // 是否启用文本提示框,可自定义设置弹出提示内容 boolean | slot
}
const props = withDefaults(defineProps<Props>(), {
maxWidth: '100%',
tooltipMaxWidth: undefined,
line: undefined,
expand: false,
tooltip: true
});
const showTooltip = ref(false); // 是否显示提示框
const showExpand = ref(false); // 是否可以启用点击展开
const ellipsisRef = ref();
const ellipsisLine = ref(); // 行数
const stopObservation = ref(false);
const emit = defineEmits(['expandChange']);
const textMaxWidth = computed(() => {
if (typeof props.maxWidth === 'number') {
return `${props.maxWidth}px`;
}
return props.maxWidth;
});
watch(
() => props.line,
(to) => {
if (to !== undefined) {
ellipsisLine.value = to;
} else {
ellipsisLine.value = 'none';
}
},
{
immediate: true
}
);
watch(
() => [props.maxWidth, props.line, props.tooltip],
() => {
updateTooltipShow();
},
{
deep: true,
flush: 'post'
}
);
useResizeObserver(ellipsisRef, () => {
if (stopObservation.value) {
setTimeout(() => {
stopObservation.value = false;
});
} else {
updateTooltipShow();
}
});
onMounted(() => {
updateTooltipShow();
});
function updateTooltipShow() {
const scrollWidth = ellipsisRef.value.scrollWidth;
const scrollHeight = ellipsisRef.value.scrollHeight;
const clientWidth = ellipsisRef.value.clientWidth;
const clientHeight = ellipsisRef.value.clientHeight;
if (scrollWidth > clientWidth || scrollHeight > clientHeight) {
if (props.expand) {
showExpand.value = true;
}
if (props.tooltip) {
showTooltip.value = true;
}
} else {
if (props.expand) {
showExpand.value = false;
}
if (props.tooltip) {
showTooltip.value = false;
}
}
}
function onExpand() {
stopObservation.value = true;
if (ellipsisLine.value !== 'none') {
ellipsisLine.value = 'none';
if (props.tooltip && showTooltip.value) {
showTooltip.value = false;
}
emit('expandChange', true);
} else {
ellipsisLine.value = props.line ?? 'none';
if (props.tooltip && !showTooltip.value) {
showTooltip.value = true;
}
emit('expandChange', false);
}
}
</script>
<template>
<Tooltip
:style="`max-width: ${textMaxWidth}`"
:max-width="tooltipMaxWidth || `calc(${textMaxWidth} + 24px)`"
:content-style="{ maxWidth: textMaxWidth }"
:tooltip-style="{ padding: '8px 12px' }"
:transition-duration="200"
v-bind="$attrs"
>
<template #tooltip>
<slot v-if="showTooltip" name="tooltip">
<slot></slot>
</slot>
</template>
<div
ref="ellipsisRef"
class="m-ellipsis"
:class="[line ? 'ellipsis-line' : 'not-ellipsis-line', { 'ellipsis-cursor-pointer': showExpand }]"
:style="`--ellipsis-max-width: ${textMaxWidth}; --ellipsis-line: ${ellipsisLine};`"
@click="showExpand ? onExpand() : () => false"
>
<slot></slot>
</div>
</Tooltip>
</template>
<style lang="less" scoped>
.m-ellipsis {
overflow: hidden;
cursor: text;
max-width: var(--ellipsis-max-width);
}
.ellipsis-line {
display: -webkit-inline-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--ellipsis-line);
}
.not-ellipsis-line {
display: inline-block;
vertical-align: bottom;
white-space: nowrap;
text-overflow: ellipsis;
}
.ellipsis-cursor-pointer {
cursor: pointer;
}
</style>

View File

@@ -1,140 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { CSSProperties } from 'vue';
import { useSlotsExist } from '../Utils';
interface Props {
description?: string // 自定义描述内容 string | slot
descriptionStyle?: CSSProperties // 设置描述文本的样式
image?: 'filled' | 'outlined' | string // 显示图片的链接,或者 选择两种预置风格图片 string | slot
imageStyle?: CSSProperties // 设置图片的样式
footer?: string // 设置底部内容 string | slot
}
const props = withDefaults(defineProps<Props>(), {
description: '暂无数据',
descriptionStyle: () => ({}),
image: 'filled',
imageStyle: () => ({}),
footer: undefined
});
const slotsExist = useSlotsExist(['default', 'description', 'footer']);
const showDescription = computed(() => {
return slotsExist.description || props.description;
});
const showFooter = computed(() => {
return slotsExist.footer || props.footer;
});
</script>
<template>
<div class="m-empty" :class="{ 'empty-image-outlined': image === 'outlined' }">
<div class="m-empty-image" :style="imageStyle">
<slot v-if="slotsExist.default"></slot>
<svg
v-else-if="image === 'filled'"
class="empty-filled"
:style="imageStyle"
viewBox="0 0 184 152"
xmlns="http://www.w3.org/2000/svg"
>
<g fill="none" fill-rule="evenodd">
<g transform="translate(24 31.67)">
<ellipse fill-opacity=".8" fill="#F5F5F7" cx="67.797" cy="106.89" rx="67.797" ry="12.668"></ellipse>
<path
d="M122.034 69.674L98.109 40.229c-1.148-1.386-2.826-2.225-4.593-2.225h-51.44c-1.766 0-3.444.839-4.592 2.225L13.56 69.674v15.383h108.475V69.674z"
fill="#AEB8C2"
></path>
<path
d="M101.537 86.214L80.63 61.102c-1.001-1.207-2.507-1.867-4.048-1.867H31.724c-1.54 0-3.047.66-4.048 1.867L6.769 86.214v13.792h94.768V86.214z"
fill="url(#linearGradient-1)"
transform="translate(13.56)"
></path>
<path
d="M33.83 0h67.933a4 4 0 0 1 4 4v93.344a4 4 0 0 1-4 4H33.83a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4z"
fill="#F5F5F7"
></path>
<path
d="M42.678 9.953h50.237a2 2 0 0 1 2 2V36.91a2 2 0 0 1-2 2H42.678a2 2 0 0 1-2-2V11.953a2 2 0 0 1 2-2zM42.94 49.767h49.713a2.262 2.262 0 1 1 0 4.524H42.94a2.262 2.262 0 0 1 0-4.524zM42.94 61.53h49.713a2.262 2.262 0 1 1 0 4.525H42.94a2.262 2.262 0 0 1 0-4.525zM121.813 105.032c-.775 3.071-3.497 5.36-6.735 5.36H20.515c-3.238 0-5.96-2.29-6.734-5.36a7.309 7.309 0 0 1-.222-1.79V69.675h26.318c2.907 0 5.25 2.448 5.25 5.42v.04c0 2.971 2.37 5.37 5.277 5.37h34.785c2.907 0 5.277-2.421 5.277-5.393V75.1c0-2.972 2.343-5.426 5.25-5.426h26.318v33.569c0 .617-.077 1.216-.221 1.789z"
fill="#DCE0E6"
></path>
</g>
<path
d="M149.121 33.292l-6.83 2.65a1 1 0 0 1-1.317-1.23l1.937-6.207c-2.589-2.944-4.109-6.534-4.109-10.408C138.802 8.102 148.92 0 161.402 0 173.881 0 184 8.102 184 18.097c0 9.995-10.118 18.097-22.599 18.097-4.528 0-8.744-1.066-12.28-2.902z"
fill="#DCE0E6"
></path>
<g transform="translate(149.65 15.383)" fill="#FFF">
<ellipse cx="20.654" cy="3.167" rx="2.849" ry="2.815"></ellipse>
<path d="M5.698 5.63H0L2.898.704zM9.259.704h4.985V5.63H9.259z"></path>
</g>
</g>
</svg>
<svg
v-else-if="image === 'outlined'"
class="empty-outlined"
:style="imageStyle"
viewBox="0 0 64 41"
xmlns="http://www.w3.org/2000/svg"
>
<g transform="translate(0 1)" fill="none" fill-rule="evenodd">
<ellipse fill="#f5f5f5" cx="32" cy="33" rx="32" ry="7"></ellipse>
<g fill-rule="nonzero" stroke="#d9d9d9">
<path
d="M55 12.76L44.854 1.258C44.367.474 43.656 0 42.907 0H21.093c-.749 0-1.46.474-1.947 1.257L9 12.761V22h46v-9.24z"
></path>
<path
d="M41.613 15.931c0-1.605.994-2.93 2.227-2.931H55v18.137C55 33.26 53.68 35 52.05 35h-40.1C10.32 35 9 33.259 9 31.137V13h11.16c1.233 0 2.227 1.323 2.227 2.928v.022c0 1.605 1.005 2.901 2.237 2.901h14.752c1.232 0 2.237-1.308 2.237-2.913v-.007z"
fill="#fafafa"
></path>
</g>
</g>
</svg>
<img v-else-if="image" class="empty-image" :src="image" alt="empty" />
</div>
<p v-if="showDescription" class="empty-description" :style="descriptionStyle">
<slot name="description">{{ description }}</slot>
</p>
<div v-if="showFooter" class="empty-footer">
<slot name="footer">{{ footer }}</slot>
</div>
</div>
</template>
<style lang="less" scoped>
.m-empty {
margin-inline: 8px;
text-align: center;
.m-empty-image {
height: 100px;
margin-bottom: 8px;
opacity: 1;
.empty-filled {
display: inline-block;
vertical-align: bottom;
width: 184px;
height: 100px;
}
.empty-outlined {
display: inline-block;
vertical-align: bottom;
width: 64px;
height: 40px;
}
.empty-image {
display: inline-block;
height: 100%;
vertical-align: middle;
}
}
.empty-description {
font-size: 14px;
color: rgba(0, 0, 0, 0.25);
line-height: 1.5714285714285714;
}
.empty-footer {
margin-top: 16px;
}
}
.empty-image-outlined {
margin-block: 32px;
.m-empty-image {
height: 40px;
}
}
</style>

View File

@@ -1,75 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
width?: string | number // 弹性区域总宽度,单位 px
vertical?: boolean // flex 主轴的方向是否垂直vertical 使用 flex-direction: column
wrap?: 'nowrap' | 'wrap' | 'wrap-reverse' // 设置元素单行显示还是多行显示;参考 flex-wrap
justify?: string // 设置元素在主轴方向上的对齐方式;参考 justify-content
align?: string // 设置元素在交叉轴方向上的对齐方式;参考 align-items
gap?: number | number[] | 'small' | 'middle' | 'large' // 设置网格之间的间隙,数组时表示: [水平间距, 垂直间距]
}
const props = withDefaults(defineProps<Props>(), {
width: 'auto',
vertical: false,
wrap: 'nowrap',
justify: 'normal',
align: 'normal',
gap: 'middle'
});
const flexWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`;
}
return props.width;
});
const gapValue = computed(() => {
if (props.gap === undefined) {
return 0;
}
if (typeof props.gap === 'number') {
return `${props.gap}px`;
}
if (Array.isArray(props.gap)) {
return `${props.gap[1]}px ${props.gap[0]}px`;
}
if (['small', 'middle', 'large'].includes(props.gap)) {
const gapMap = {
small: '8px',
middle: '16px',
large: '24px'
};
return gapMap[props.gap];
}
return '0';
});
</script>
<template>
<div
class="m-flex"
:class="{ 'flex-vertical': vertical }"
:style="`
width: ${flexWidth};
gap: ${gapValue};
margin-bottom: -${Array.isArray(props.gap) && wrap ? props.gap[1] : 0}px;
--wrap: ${wrap};
--justify: ${justify};
--align: ${align};
`"
>
<slot></slot>
</div>
</template>
<style lang="less" scoped>
.m-flex {
display: flex;
flex-wrap: var(--wrap);
justify-content: var(--justify);
align-items: var(--align);
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
transition: all 0.3s;
}
.flex-vertical {
flex-direction: column;
}
</style>

View File

@@ -1,398 +0,0 @@
<script setup lang="ts">
import {computed, ref, watch} from 'vue';
import Tooltip from '../Tooltip/Tooltip.vue';
import Badge from '../Badge/Badge.vue';
import {useSlotsExist} from '../Utils';
interface Props {
top?: number | string // 按钮定位的上边距,单位 px
bottom?: number | string // 按钮定位的下边距,单位 px
left?: number | string // 按钮定位的左边距,单位 px
right?: number | string // 按钮定位的右边距,单位 px
width?: number | string // 浮动按钮宽度,单位 px
height?: number | string // 浮动按钮高度,单位 px
type?: 'default' | 'primary' // 浮动按钮类型
shape?: 'circle' | 'square' // 浮动按钮形状
icon?: string // 浮动按钮图标 string | slot
description?: string // 文字描述信息 string | slot
href?: string // 点击跳转的地址,指定此属性按钮的行为和 a 链接一致
target?: '_self' | '_blank' // 相当于 a 标签的 target 属性href 存在时生效
menuTrigger?: 'click' | 'hover' // 浮动按钮菜单显示的触发方式
tooltip?: string // 气泡卡片的内容 string | slot
tooltipProps?: object // Tooltip 组件属性配置,参考 Tooltip Props
badgeProps?: object // 带徽标的浮动按钮(不支持 status 以及相关属性),参考 Badge Props
}
const props = withDefaults(defineProps<Props>(), {
top: undefined,
bottom: 40,
left: undefined,
right: 40,
width: 44,
height: 44,
type: 'default',
shape: 'circle',
icon: undefined,
description: undefined,
href: undefined,
target: '_self',
menuTrigger: undefined,
tooltip: undefined,
tooltipProps: () => ({}),
badgeProps: () => ({})
});
const showMenu = ref(false);
const emits = defineEmits(['click', 'openChange']);
const slotsExist = useSlotsExist(['icon', 'description', 'tooltip', 'menu']);
const floatBtnWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`;
}
return props.width;
});
const floatBtnHeight = computed(() => {
if (typeof props.height === 'number') {
return `${props.height}px`;
}
return props.height;
});
const floatBtnLeft = computed(() => {
if (typeof props.left === 'number') {
return `${props.left}px`;
}
return props.left;
});
const floatBtnRight = computed(() => {
if (props.left) {
return null;
} else {
if (typeof props.right === 'number') {
return `${props.right}px`;
}
return props.right;
}
});
const floatBtnTop = computed(() => {
if (typeof props.top === 'number') {
return `${props.top}px`;
}
return props.top;
});
const floatBtnBottom = computed(() => {
if (props.top) {
return null;
} else {
if (typeof props.bottom === 'number') {
return `${props.bottom}px`;
}
return props.bottom;
}
});
const showDescription = computed(() => {
return slotsExist.description || props.description;
});
const showTooltip = computed(() => {
return slotsExist.tooltip || props.tooltip;
});
watch(showMenu, (to) => {
emits('openChange', to);
});
function onClick(e: Event) {
emits('click', e);
if (props.menuTrigger === 'click' && slotsExist.menu) {
showMenu.value = !showMenu.value;
}
}
</script>
<template>
<component
:is="href ? 'a' : 'div'"
tabindex="0"
class="m-float-btn"
:class="`float-btn-${type} float-btn-${shape}`"
:style="`
--float-btn-width: ${floatBtnWidth};
--float-btn-height: ${floatBtnHeight};
--float-btn-left: ${floatBtnLeft};
--float-btn-right: ${floatBtnRight};
--float-btn-top: ${floatBtnTop};
--float-btn-bottom: ${floatBtnBottom}
`"
:href="href"
:target="target"
@click="onClick"
@blur="menuTrigger === 'click' ? (showMenu = false) : null"
@mouseenter="menuTrigger === 'hover' ? (showMenu = true) : null"
@mouseleave="menuTrigger === 'hover' ? (showMenu = false) : null"
>
<Tooltip placement="left" v-bind="tooltipProps" class="float-btn-tooltip">
<template v-if="showTooltip" #tooltip>
<slot name="tooltip">{{ tooltip }}</slot>
</template>
<Badge v-bind="badgeProps">
<div class="float-btn-body">
<div class="float-btn-content">
<div v-if="slotsExist.icon" class="float-btn-icon">
<Transition name="fade">
<slot v-if="!showMenu" name="icon"></slot>
<svg
v-else
class="close-svg"
focusable="false"
data-icon="close"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
fill-rule="evenodd"
viewBox="64 64 896 896"
>
<path
d="M799.86 166.31c.02 0 .04.02.08.06l57.69 57.7c.04.03.05.05.06.08a.12.12 0 010 .06c0 .03-.02.05-.06.09L569.93 512l287.7 287.7c.04.04.05.06.06.09a.12.12 0 010 .07c0 .02-.02.04-.06.08l-57.7 57.69c-.03.04-.05.05-.07.06a.12.12 0 01-.07 0c-.03 0-.05-.02-.09-.06L512 569.93l-287.7 287.7c-.04.04-.06.05-.09.06a.12.12 0 01-.07 0c-.02 0-.04-.02-.08-.06l-57.69-57.7c-.04-.03-.05-.05-.06-.07a.12.12 0 010-.07c0-.03.02-.05.06-.09L454.07 512l-287.7-287.7c-.04-.04-.05-.06-.06-.09a.12.12 0 010-.07c0-.02.02-.04.06-.08l57.7-57.69c.03-.04.05-.05.07-.06a.12.12 0 01.07 0c.03 0 .05.02.09.06L512 454.07l287.7-287.7c.04-.04.06-.05.09-.06a.12.12 0 01.07 0z"
></path>
</svg>
</Transition>
</div>
<div v-if="showDescription" class="float-btn-description">
<slot name="description">{{ description }}</slot>
</div>
</div>
</div>
</Badge>
</Tooltip>
<Transition v-show="showMenu" name="move">
<div class="float-btn-menu">
<slot name="menu"></slot>
</div>
</Transition>
</component>
</template>
<style lang="less" scoped>
.fade-move,
.fade-enter-active,
.fade-leave-active {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.fade-enter-from,
.fade-leave-to {
transform: scale(0.75);
opacity: 0;
}
.fade-leave-active {
position: absolute;
}
.move-enter-active,
.move-leave-active {
transform-origin: 0 0;
transition: all 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
}
.move-leave-active {
pointer-events: none;
}
.move-enter-from,
.move-leave-to {
transform: translate3d(0, var(--float-btn-height), 0);
transform-origin: 0 0;
opacity: 0;
}
.m-float-btn {
position: fixed;
left: var(--float-btn-left);
right: var(--float-btn-right);
top: var(--float-btn-top);
bottom: var(--float-btn-bottom);
z-index: 99;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
display: inline-block;
width: var(--float-btn-width);
height: var(--float-btn-height);
cursor: pointer;
outline: none;
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
.float-btn-tooltip {
width: 100%;
height: 100%;
:deep(.tooltip-content) {
width: 100%;
height: 100%;
.m-badge {
vertical-align: top;
width: 100%;
height: 100%;
.m-badge-value:not(.only-dot) {
transform: translate(0, 0);
transform-origin: center;
top: -6px;
right: -6px;
}
}
}
}
.float-btn-body {
position: relative;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.2s;
.float-btn-content {
overflow: hidden;
text-align: center;
min-height: var(--float-btn-height);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 2px 4px;
.float-btn-icon {
font-size: 18px;
line-height: 1;
.close-svg {
display: inline-block;
vertical-align: bottom;
}
:deep(svg) {
fill: currentColor;
}
:deep(img) {
vertical-align: bottom;
}
}
}
}
.float-btn-menu {
position: absolute;
bottom: 100%;
display: block;
z-index: -1;
.m-float-btn {
position: static;
}
& > * {
margin-bottom: 16px;
}
}
}
.float-btn-default {
background-color: #ffffff;
transition: background-color 0.2s;
& > .float-btn-tooltip {
.float-btn-body {
background-color: #ffffff;
transition: background-color 0.2s;
&:hover {
background-color: rgba(0, 0, 0, 0.06);
}
.float-btn-content {
.float-btn-icon {
color: rgba(0, 0, 0, 0.88);
}
.float-btn-description {
display: flex;
align-items: center;
line-height: 16px;
color: rgba(0, 0, 0, 0.88);
font-size: 12px;
}
}
}
}
}
.float-btn-primary {
background-color: @themeColor;
& > .float-btn-tooltip {
.float-btn-body {
background-color: @themeColor;
transition: background-color 0.2s;
&:hover {
background-color: #4096ff;
}
.float-btn-content {
.float-btn-icon {
color: #fff;
}
}
.float-btn-description {
display: flex;
align-items: center;
line-height: 16px;
color: #fff;
font-size: 12px;
}
}
}
}
.float-btn-circle {
border-radius: 50%;
:deep(.m-badge) {
.only-dot {
top: 5.857864376269049px;
right: 5.857864376269049px;
}
}
& > .float-btn-tooltip {
.float-btn-body {
border-radius: 50%;
}
}
}
.float-btn-square {
height: auto;
min-height: var(--float-btn-height);
border-radius: 8px;
:deep(.m-badge) {
.only-dot {
top: 2.3431457505076194px;
right: 2.3431457505076194px;
}
}
& > .float-btn-tooltip {
.float-btn-body {
height: auto;
border-radius: 8px;
}
}
}
</style>

View File

@@ -1,408 +0,0 @@
<script setup lang="ts">
import {computed, onMounted, ref, watch} from 'vue';
/*
按需引入
*/
// 使用 ECharts 提供的按需引入的接口来打包必须的组件
// 引入 echarts 核心模块,核心模块提供了 echarts 使用必须要的接口
import * as echarts from 'echarts/core';
// 引入仪表盘图表,图表后缀都为 Chart
import {GaugeChart} from 'echarts/charts';
// 引入提示框,组件后缀都为 Component
import {TooltipComponent} from 'echarts/components';
// 引入 Canvas 渲染器,注意引入 CanvasRenderer 或者 SVGRenderer 是必须的一步
import {CanvasRenderer} from 'echarts/renderers';
// 注册必须的组件
echarts.use([GaugeChart, TooltipComponent, CanvasRenderer]);
/*
全部引入
*/
// import * as echarts from 'echarts'
/*
需要注意的是为了保证打包的体积是最小的ECharts 按需引入的时候不再提供任何渲染器,
所以需要选择引入 CanvasRenderer 或者 SVGRenderer 作为渲染器。这样的好处是假如
你只需要使用 svg 渲染模式,打包的结果中就不会再包含无需使用的 CanvasRenderer 模块
*/
const chart = ref();
const gaugeChart = ref();
const gradient = ref({ // 自定义渐变色
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0, color: '#FF6E76' // 0% 处的颜色
},
{
offset: 0.25, color: '#FDDD60' // 25% 处的颜色
},
{
offset: 0.75, color: '#58D9F9' // 75% 处的颜色
},
{
offset: 1, color: '#7CFFB2' // 100% 处的颜色
}
],
global: false // 缺省为 false
});
var option: any;
interface Gauge {
name: string // 数据项名称
value: number // 数据值
[propName: string]: any // 添加一个字符串索引签名,用于包含带有任意数量的其他属性
}
interface Props {
gaugeData: Gauge[] // 仪表盘数据源
width?: string | number // 容器宽度
height?: string | number // 容器高度
themeColor?: string // 主题色
}
const props = withDefaults(defineProps<Props>(), {
gaugeData: () => [],
width: '100%',
height: '100%',
themeColor: '#1677FF'
});
const chartWidth = computed(() => {
if (typeof props.width === 'number') {
return props.width + 'px';
} else {
return props.width;
}
});
const chartHeight = computed(() => {
if (typeof props.height === 'number') {
return props.height + 'px';
} else {
return props.height;
}
});
onMounted(() => {
initChart(); // 初始化图标示例
});
watch(
() => props.gaugeData,
(to) => {
// 监听并更新图例数据
option.series[0].data = to;
gaugeChart.value.setOption(option);
},
{
deep: true
}
);
watch(
() => [props.width, props.height, props.themeColor],
() => {
if (gaugeChart.value) {
gaugeChart.value.dispose(); // 销毁实例
}
initChart(); // 重新初始化实例
},
{
deep: true,
flush: 'post'
}
);
// const loadingConfig = {
// text: 'loading',
// color: '#c23531',
// textColor: '#000',
// maskColor: 'rgba(255, 255, 255, 0.8)',
// zlevel: 0,
// 字体大小。从 `v4.8.0` 开始支持。
// fontSize: 12,
// 是否显示旋转动画spinner。从 `v4.8.0` 开始支持。
// showSpinner: true,
// 旋转动画spinner的半径。从 `v4.8.0` 开始支持。
// spinnerRadius: 20,
// 旋转动画spinner的线宽。从 `v4.8.0` 开始支持。
// lineWidth: 5,
// 字体粗细。从 `v5.0.1` 开始支持。
// fontWeight: 'normal',
// 字体风格。从 `v5.0.1` 开始支持。
// fontStyle: 'normal',
// 字体系列。从 `v5.0.1` 开始支持。
// fontFamily: 'sans-serif'
// }
function showLoading(config: any) {
gaugeChart.value.showLoading('default', {text: '', color: props.themeColor, ...config}); // 显示加载动画效果
}
function hideLoading() {
gaugeChart.value.hideLoading(); // 隐藏动画加载效果
}
defineExpose({
showLoading,
hideLoading
});
function initChart() {
// 等价于使用 Canvas 渲染器默认echarts.init(containerDom, null, { renderer: 'canvas' })
gaugeChart.value = echarts.init(chart.value);
option = {
tooltip: { // 提示框浮层设置
trigger: 'item',
triggerOn: 'mousemove', // 提示框触发条件
enterable: true, // 鼠标是否可进入提示框浮层中默认false
confine: true, // 是否将tooltip框限制在图表的区域内
formatter: function (params: any) { // 提示框浮层内容格式器,支持字符串模板和回调函数两种形式
// console.log('params:', params)
return params.marker + params.name + ': ' + params.value || '--';
},
backgroundColor: 'transparent', // 提示框浮层的背景颜色
borderColor: '#7CFFB2', // 提示框浮层的边框颜色
borderWidth: 1, // 提示框浮层的边框宽
borderRadius: 6, // 提示框浮层圆角
padding: [6, 12], // 提示框浮层的内边距
textStyle: { // 提示框浮层的文本样式
color: '#333', // 文字颜色
fontWeight: 600, // 字体粗细
fontSize: 18, // 字体大小
// lineHeight: 24, // 行高
// width: 60, // 文本显示宽度
// 文字超出宽度是否截断或者换行只有配置width时有效
overflow: 'breakAll', // truncate截断并在末尾显示ellipsis配置的文本默认为...;break换行;breakAll换行并强制单词内换行
ellipsis: '...'
},
},
color: [props.themeColor],
series: [
{
type: 'gauge',
name: '仪表盘', // 系列名称用于tooltip的显示
/*
从调色盘 option.color 中取色的策略,可选 'series' | 'data'
'series': 按照系列分配调色盘中的颜色,同一系列中的所有数据都是用相同的颜色
'data': 按照数据项分配调色盘中的颜色,每个数据项都使用不同的颜色
*/
colorBy: 'data',
center: ['50%', '65%'], // 圆心坐标,[400, 300]: 数组的第一项是横坐标,第二项是纵坐标,支持设置成百分比,['50%', '75%']: 设置成百分比时第一项是相对于容器宽度,第二项是相对于容器高度
radius: '100%', // 仪表盘半径,可以是相对于容器高宽中较小的一项的一半的百分比,也可以是绝对的数值。
legendHoverLink: true, // 是否启用图例 hover 时的联动高亮
startAngle: 210, // 仪表盘起始角度。圆心 正右手侧为0度正上方为90度正左手侧为180度。
endAngle: -30, // 仪表盘结束角度。
clockwise: true, // 仪表盘刻度是否是顺时针增长
min: 0, // 最小的数据值,映射到 minAngle
max: 100, // 最大的数据值,映射到 maxAngle
splitNumber: 10, // 仪表盘刻度的分割段数
axisLine: { // 仪表盘轴线相关配置
show: true, // 是否显示仪表盘轴线
roundCap: true, // 是否在两端显示成圆形
lineStyle: { // 仪表盘轴线样式
width: 30, // 轴线宽度
// color: [ // 仪表盘的轴线可以被分成不同颜色的多段。每段的结束位置和颜色可以通过一个数组来表示
// [20, '#FF6E76'],
// [40, '#FDDD60'],
// [60, '#58D9F9'],
// [80, '#7CFFB2'],
// [100, '#1677FF']
// ],
// shadowBlur: 10, // 图形阴影的模糊大小
// shadowColor: 'rgba(0, 0, 0, 0.5)', // 阴影颜色
// shadowOffsetX: 3, // 阴影水平方向上的偏移距离
// shadowOffsetY: 3, // 阴影垂直方向上的偏移距离
// opacity: 1 // 图形透明度。支持从 0 到 1 的数字,为 0 时不绘制该图形。
}
},
progress: { // 展示当前进度
show: true, // 是否显示进度条
overlap: false, // 多组数据时进度条是否重叠
// width: 12, // 进度条宽度
roundCap: true, // 是否在两端显示成圆形
clip: true, // 是否裁掉超出部分
itemStyle: { // 进度条样式
color: gradient.value, // 图形的颜色
// borderColor: '#1677FF', // 图形的描边颜色
// borderWidth: 1, // 描边线宽。为 0 时无描边。
// borderType: 'solid', // 描边类型,可选:'solid' 'dashed' 'dotted'
}
},
splitLine: { // 分隔线样式
show: true, // 是否显示分隔线
length: 30, // 分隔线线长。支持相对半径的百分比
distance: 10, // 分隔线与轴线的距离
lineStyle: { // 分隔线样式
color: 'auto', // 线的颜色
width: 5, // 线宽
type: 'solid', // 线的类型,可选 'solid' 'dashed' 'dotted'
cap: 'butt' // 指定线段末端的绘制方式,可选 'butt'(默认) 线段末端以方形结束 'round' 线段末端以圆形结束 'square' 线段末端以方形结束,但是增加了一个宽度和线段相同,高度是线段厚度一半的矩形区域
}
},
axisTick: { // 刻度样式
show: true, // 是否显示刻度
splitNumber: 10, // 分隔线之间分割的刻度数
length: 12, // 刻度线长。支持相对半径的百分比
distance: 10, // 刻度线与轴线的距离
lineStyle: { // 刻度线样式
color: 'auto', // 线的颜色
width: 2, // 线宽
type: 'solid', // 线的类型,可选 'solid' 'dashed' 'dotted'
cap: 'butt' // 指定线段末端的绘制方式,可选 'butt'(默认) 线段末端以方形结束 'round' 线段末端以圆形结束 'square' 线段末端以方形结束,但是增加了一个宽度和线段相同,高度是线段厚度一半的矩形区域
}
},
axisLabel: { // 刻度标签
show: true, // 是否显示标签
distance: -80, // 标签与刻度线的距离
/*
如果是 number 类型,则表示标签的旋转角,从 -90 度到 90 度,正值是逆时针。
除此之外,还可以是字符串 'radial' 表示径向旋转、'tangential' 表示切向旋转。
如果不需要文字旋转,可以将其设为 0。
*/
rotate: 'tangential',
// 刻度标签的内容格式器,支持字符串模板和回调函数两种形式
// formatter: '{value} kg', // // 使用字符串模板,模板变量为刻度默认标签 {value}
formatter: function (value: number) {// 使用函数模板,函数参数分别为刻度数值
// console.log('value', value)
if (value === 90) {
return 'A';
} else if (value === 70) {
return 'B';
} else if (value === 50) {
return 'C';
} else if (value === 30) {
return 'D';
} else if (value === 10) {
return 'F';
}
return '';
},
color: '#aaa', // 文字的颜色
fontStyle: 'normal', // 文字字体的风格,可选 'normal' 'italic' 'oblique'
fontWeight: 'bold', // 文字字体的粗细,可选 'normal' 'bold' 'bolder' 'lighter' 100 | 200 | 300 | 400...
fontFamily: 'sans-serif', // 文字的字体系列,还可以是 'serif' , 'monospace', 'Arial', 'Courier New', 'Microsoft YaHei', ...
fontSize: 40 // 文字的字体大小
// lineHeight: 28, // 行高
/*
可以使用颜色值,例如:'#123234', 'red', 'rgba(0,23,11,0.3)'
也可以直接使用图片,例如:
backgroundColor: {
image: 'xxx/xxx.png'
// 这里可以是图片的 URL
// 或者图片的 dataURI
// 或者 HTMLImageElement 对象,
// 或者 HTMLCanvasElement 对象。
}
*/
// backgroundColor: 'transparent', // 文字块背景色
// borderColor: 'red', // 文字块边框颜色
// borderWidth: 3, // 文字块边框宽度
// borderType: 'solid', // 文字块边框描边类型,可选 'solid' 'dashed' 'dotted'
// borderRadius: 10, // 文字块的圆角
// padding: [6, 12], // 文字块的内边距,文字块宽高不包含 padding
// width: 60, // 文本显示宽度
// height: 60 // 文本显示高度
},
pointer: { // 仪表盘指针
show: true, // 是否显示指针
showAbove: true, // 指针是否显示在标题和仪表盘详情上方
// 可以通过 'image://url' 设置为图片,其中 URL 为图片的链接,或者 dataURI。
icon: 'diamond', // 标记类型,可选 'circle', 'rect', 'roundRect', 'triangle', 'diamond', 'pin', 'arrow', 'none'
offsetCenter: [0, '-28%'], // 相对于仪表盘中心的偏移位置,数组第一项是水平方向的偏移,第二项是垂直方向的偏移。可以是绝对的数值,也可以是相对于仪表盘半径的百分比。
length: '36%', // 指针长度,可以是绝对数值,也可以是相对于半径的半分比。
width: 20, // 指针宽度
itemStyle: { // 指针样式
// color: 'auto' // 图形颜色
// 线性渐变,前四个参数分别是 x0, y0, x2, y2, 范围从 0 - 1相当于在图形包围盒中的百分比如果 globalCoord 为 `true`,则该四个值是绝对的像素位置
color: gradient.value
// borderColor: '#000', // 图形的描边颜色
// borderWidth: 3, // 描边线宽。为 0 时无描边
// borderType: 'solid', // 描边类型,可选:'solid' 'dashed' 'dotted'
}
},
anchor: { // 表盘中指针的固定点
show: true, // 是否显示固定点
showAbove: true, // 固定点是否显示在指针上面
size: 24, // 固定点大小
// 可以通过 'image://url' 设置为图片,其中 URL 为图片的链接,或者 dataURI。
icon: 'circle', // 标记类型,可选 'circle', 'rect', 'roundRect', 'triangle', 'diamond', 'pin', 'arrow', 'none'
offsetCenter: [0, '16%'], // 相对于仪表盘中心的偏移位置,数组第一项是水平方向的偏移,第二项是垂直方向的偏移。可以是绝对的数值,也可以是相对于仪表盘半径的百分比。
itemStyle: { // 指针固定点样式
color: props.themeColor, // 图形的颜色
borderColor: '#eee', // 固定点边框颜色
borderWidth: 8, // 描边线宽。为 0 时无描边
borderType: 'solid', // 描边类型,可选:'solid' 'dashed' 'dotted'
}
},
title: { // 仪表盘标题
show: true, // 是否显示标题
offsetCenter: [0, '36%'], // 相对于仪表盘中心的偏移位置,数组第一项是水平方向的偏移,第二项是垂直方向的偏移。可以是绝对的数值,也可以是相对于仪表盘半径的百分比。
color: '#464646', // 文字的颜色
fontStyle: 'normal', // 文字字体的风格,可选 'normal' 'italic' 'oblique'
fontWeight: 'bold', // 文字字体的粗细,可选 'normal' 'bold' 'bolder' 'lighter' 100 | 200 | 300 | 400...
fontFamily: 'sans-serif', // 文字的字体系列,还可以是 'serif' , 'monospace', 'Arial', 'Courier New', 'Microsoft YaHei', ...
fontSize: 45 // 文字的字体大小
// lineHeight: 48, // 行高
/*
可以使用颜色值,例如:'#123234', 'red', 'rgba(0,23,11,0.3)'
也可以直接使用图片,例如:
backgroundColor: {
image: 'xxx/xxx.png'
// 这里可以是图片的 URL
// 或者图片的 dataURI
// 或者 HTMLImageElement 对象,
// 或者 HTMLCanvasElement 对象。
}
*/
// backgroundColor: 'transparent', // 文字块背景色
// borderColor: 'red', // 文字块边框颜色
// borderWidth: 3, // 文字块边框宽度
// borderType: 'solid', // 文字块边框描边类型,可选 'solid' 'dashed' 'dotted'
// borderRadius: 10, // 文字块的圆角
// padding: [6, 12], // 文字块的内边距,文字块宽高不包含 padding
// width: 60, // 文本显示宽度
// height: 60 // 文本显示高度
},
detail: { // 仪表盘详情,用于显示数据,即表盘中心的数据展示
show: true, // 是否显示详情
color: props.themeColor, // 文本颜色
fontStyle: 'normal', // 文字字体的风格,可选 'normal' 'italic' 'oblique'
fontWeight: 'bold', // 文字字体的粗细,可选 'normal' 'bold' 'bolder' 'lighter' 100 | 200 | 300 | 400...
fontFamily: 'Microsoft YaHei', // 文字的字体系列,还可以是 'serif' , 'monospace', 'Arial', 'Courier New', 'Microsoft YaHei', ...
fontSize: 72, // 文字的字体大小
backgroundColor: 'transparent', // 详情背景色
// borderColor: '#ccc', // 详情边框颜色
// borderWidth: 1, // 详情边框宽度
// borderType: 'solid', // 'solid' 'dashed' 'dotted'
// borderRadius: 5, // 文字块的圆角
// padding: [3, 6], // 文字块的内边距
valueAnimation: true, // 是否开启标签的数字动画
offsetCenter: [0, '-5%'], // 相对于仪表盘中心的偏移位置,数组第一项是水平方向的偏移,第二项是垂直方向的偏移。可以是绝对的数值,也可以是相对于仪表盘半径的百分比。
formatter: function (value: number) { // 格式化函数或者字符串
return value + '';
},
},
/*
系列中的数据内容数组,
数组项可以为单个数值:[12, 34, 56, 10, 23]
数据项也可为一个对象:[
{
// 数据项的名称
name: '数据1',
// 数据项值8
value: 10
},
{
name: '数据2',
value: 20
}]
对象支持的所有属性:{ title , detail , name , value , itemStyle }
*/
data: props.gaugeData
}
]
};
gaugeChart.value.setOption(option);
}
</script>
<template>
<div ref="chart" :style="`width: ${chartWidth}; height: ${chartHeight};`"></div>
</template>

View File

@@ -1,356 +0,0 @@
<script setup lang="ts">
import {computed, ref} from 'vue';
import {useEventListener} from '../Utils';
interface Props {
span?: number // 栅格占位格数,取 0,1,2...24,为 0 时相当于 display: none优先级低于 xs, sm, md, lg, xl, xxl
offset?: number // 栅格左侧的间隔格数,取 0,1,2...24
flex?: string | number // flex 布局填充
order?: number // 栅格顺序,取 0,1,2...
xs?: number | { span?: number; offset?: number } // <576px 响应式栅格
sm?: number | { span?: number; offset?: number } // ≥576px 响应式栅格
md?: number | { span?: number; offset?: number } // ≥768px 响应式栅格
lg?: number | { span?: number; offset?: number } // ≥992px 响应式栅格
xl?: number | { span?: number; offset?: number } // ≥1200px 响应式栅格
xxl?: number | { span?: number; offset?: number } // ≥1600px 响应式栅格
}
const props = withDefaults(defineProps<Props>(), {
span: undefined,
offset: 0,
flex: undefined,
order: 0,
xs: undefined,
sm: undefined,
md: undefined,
lg: undefined,
xl: undefined,
xxl: undefined
});
const flexValue = computed(() => {
if (typeof props.flex === 'number') {
return `${props.flex} ${props.flex} auto`;
}
return props.flex;
});
const responsiveProperties = computed(() => {
return [
{
width: 1600,
value: props.xxl
},
{
width: 1200,
value: props.xl
},
{
width: 992,
value: props.lg
},
{
width: 768,
value: props.md
},
{
width: 576,
value: props.sm
},
{
width: 0,
value: props.xs
}
];
});
const viewportWidth = ref(window.innerWidth);
function getViewportWidth() {
viewportWidth.value = window.innerWidth;
}
useEventListener(window, 'resize', getViewportWidth);
const responsiveValue = computed(() => {
for (const responsive of responsiveProperties.value) {
if (responsive.value && viewportWidth.value >= responsive.width) {
if (typeof responsive.value === 'object') {
return {
span: responsive.value.span || props.span,
offset: responsive.value.offset || props.offset
};
} else {
return {
span: responsive.value,
offset: props.offset
};
}
}
}
return {
span: props.span,
offset: props.offset
};
});
</script>
<template>
<div
:class="`grid-col col-${responsiveValue.span} offset-${responsiveValue.offset}`"
style="padding-left: var(--xGap); padding-right: var(--xGap)"
:style="`flex: ${flexValue}; order: ${order};`"
>
<slot></slot>
</div>
</template>
<style lang="less" scoped>
.grid-col {
position: relative;
max-width: 100%;
min-height: 1px;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
transition: all 0.3s;
}
.col-0 {
display: none;
}
.col-1 {
display: block;
flex: 0 0 4.16666666666666%;
max-width: 4.16666666666666%;
}
.offset-1 {
margin-left: 4.16666666666666%;
}
.col-2 {
display: block;
flex: 0 0 8.33333333333333%;
max-width: 8.33333333333333%;
}
.offset-2 {
margin-left: 8.33333333333333%;
}
.col-3 {
display: block;
flex: 0 0 12.5%;
max-width: 12.5%;
}
.offset-3 {
margin-left: 12.5%;
}
.col-4 {
display: block;
flex: 0 0 16.66666666666666%;
max-width: 16.66666666666666%;
}
.offset-4 {
margin-left: 16.66666666666666%;
}
.col-5 {
display: block;
flex: 0 0 20.83333333333333%;
max-width: 20.83333333333333%;
}
.offset-5 {
margin-left: 20.83333333333333%;
}
.col-6 {
display: block;
flex: 0 0 25%;
max-width: 25%;
}
.offset-6 {
margin-left: 25%;
}
.col-7 {
display: block;
flex: 0 0 29.16666666666666%;
max-width: 29.16666666666666%;
}
.offset-7 {
margin-left: 29.16666666666666%;
}
.col-8 {
display: block;
flex: 0 0 33.33333333333333%;
max-width: 33.33333333333333%;
}
.offset-8 {
margin-left: 33.33333333333333%;
}
.col-9 {
display: block;
flex: 0 0 37.5%;
max-width: 37.5%;
}
.offset-9 {
margin-left: 37.5%;
}
.col-10 {
display: block;
flex: 0 0 41.66666666666666%;
max-width: 41.66666666666666%;
}
.offset-10 {
margin-left: 41.66666666666666%;
}
.col-11 {
display: block;
flex: 0 0 45.83333333333333%;
max-width: 45.83333333333333%;
}
.offset-11 {
margin-left: 45.83333333333333%;
}
.col-12 {
display: block;
flex: 0 0 50%;
max-width: 50%;
}
.offset-12 {
margin-left: 50%;
}
.col-13 {
display: block;
flex: 0 0 54.16666666666666%;
max-width: 54.16666666666666%;
}
.offset-13 {
margin-left: 54.16666666666666%;
}
.col-14 {
display: block;
flex: 0 0 58.33333333333333%;
max-width: 58.33333333333333%;
}
.offset-14 {
margin-left: 58.33333333333333%;
}
.col-15 {
display: block;
flex: 0 0 62.5%;
max-width: 62.5%;
}
.offset-15 {
margin-left: 62.5%;
}
.col-16 {
display: block;
flex: 0 0 66.66666666666666%;
max-width: 66.66666666666666%;
}
.offset-16 {
margin-left: 66.6666666666666%;
}
.col-17 {
display: block;
flex: 0 0 70.83333333333333%;
max-width: 70.83333333333333%;
}
.offset-17 {
margin-left: 70.83333333333333%;
}
.col-18 {
display: block;
flex: 0 0 75%;
max-width: 75%;
}
.offset-18 {
margin-left: 75%;
}
.col-19 {
display: block;
flex: 0 0 79.16666666666666%;
max-width: 79.16666666666666%;
}
.offset-19 {
margin-left: 79.16666666666666%;
}
.col-20 {
display: block;
flex: 0 0 83.33333333333333%;
max-width: 83.33333333333333%;
}
.offset-20 {
margin-left: 83.33333333333333%;
}
.col-21 {
display: block;
flex: 0 0 87.5%;
max-width: 87.5%;
}
.offset-21 {
margin-left: 87.5%;
}
.col-22 {
display: block;
flex: 0 0 91.66666666666666%;
max-width: 91.66666666666666%;
}
.offset-22 {
margin-left: 91.66666666666666%;
}
.col-23 {
display: block;
flex: 0 0 95.83333333333333%;
max-width: 95.83333333333333%;
}
.offset-23 {
margin-left: 95.83333333333333%;
}
.col-24 {
display: block;
flex: 0 0 100%;
max-width: 100%;
}
.offset-24 {
margin-left: 100%;
}
</style>

View File

@@ -1,110 +0,0 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useEventListener } from '../Utils';
interface Responsive {
xs?: number // <576px 响应式栅格
sm?: number // ≥576px 响应式栅格
md?: number // ≥768px 响应式栅格
lg?: number // ≥992px 响应式栅格
xl?: number // ≥1200px 响应式栅格
xxl?: number // ≥1600px 响应式栅格
}
interface Props {
width?: string | number // 行宽度,单位 px
// 推荐使用 (16+8n)px 作为栅格间隔(n 是自然数0,1,2,3...)
gutter?: number | [number | Responsive, number | Responsive] | Responsive // 栅格间隔,可以写成像素值或支持响应式的对象写法来设置水平间隔 { xs: 8, sm: 16, md: 24}。或者使用数组形式同时设置 [水平间距, 垂直间距]
wrap?: boolean // 是否自动换行
align?: 'top' | 'middle' | 'bottom' | 'stretch' // 垂直对齐方式
justify?: 'start' | 'end' | 'center' | 'space-around' | 'space-between' | 'space-evenly' // 水平排列方式
}
const props = withDefaults(defineProps<Props>(), {
width: 'auto',
gutter: 0,
wrap: false,
align: 'top',
justify: 'start'
});
const alignProperties = {
top: 'flex-start',
middle: 'center',
bottom: 'flex-end',
stretch: 'stretch'
};
const viewportWidth = ref(window.innerWidth);
function getViewportWidth() {
viewportWidth.value = window.innerWidth;
}
useEventListener(window, 'resize', getViewportWidth);
const xGap = computed(() => {
if (typeof props.gutter === 'number') {
return props.gutter;
}
if (Array.isArray(props.gutter)) {
if (typeof props.gutter[0] === 'object') {
return getResponsiveGap(props.gutter[0]);
}
return props.gutter[0];
}
if (typeof props.gutter === 'object') {
return getResponsiveGap(props.gutter);
}
return 0;
});
const yGap = computed(() => {
if (Array.isArray(props.gutter)) {
if (typeof props.gutter[1] === 'object') {
return getResponsiveGap(props.gutter[1]);
}
return props.gutter[1];
}
return 0;
});
const rowWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`;
}
return props.width;
});
function getResponsiveGap(gutter: any) {
if (viewportWidth.value >= 1600 && gutter.xxl) {
return gutter.xxl;
}
if (viewportWidth.value >= 1200 && gutter.xl) {
return gutter.xl;
}
if (viewportWidth.value >= 992 && gutter.lg) {
return gutter.lg;
}
if (viewportWidth.value >= 768 && gutter.md) {
return gutter.md;
}
if (viewportWidth.value >= 576 && gutter.sm) {
return gutter.sm;
}
if (viewportWidth.value < 576 && gutter.xs) {
return gutter.xs;
}
return 0;
}
</script>
<template>
<div
class="m-grid-row"
:class="{ 'gutter-row': gutter }"
:style="`--xGap: ${(xGap as number) / 2}px; --justify: ${justify}; --align: ${alignProperties[align]}; width: ${rowWidth}; margin-left: -${(xGap as number) / 2}px; margin-right: -${(xGap as number) / 2}px; row-gap: ${yGap}px;`"
>
<slot></slot>
</div>
</template>
<style lang="less" scoped>
.m-grid-row {
display: flex;
flex-flow: row wrap;
justify-content: var(--justify);
align-items: var(--align);
min-width: 0;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
transition: all 0.3s;
}
</style>

View File

@@ -1,841 +0,0 @@
<script setup lang="ts">
import {computed, nextTick, ref, watchEffect} from 'vue';
import Space from '../Space/Space.vue';
import Spin from '../Spin/Spin.vue';
import {add} from '../Utils';
interface Image {
src: string // 图像地址
name?: string // 图像名称
}
interface Props {
src?: string | Image[] // 图像地址或图像地址数组
name?: string // 图像名称,没有传入图片名时自动从图像地址 src 中读取
width?: string | number | (string | number)[] // 图像宽度,单位 px
height?: string | number | (string | number)[] // 图像高度,单位 px
bordered?: boolean // 是否显示边框
fit?: 'contain' | 'fill' | 'cover' | 'none' | 'scale-down' // 图片在容器内的的适应类型
preview?: string // 预览文本 string | slot
spaceProps?: object // Space 组件属性配置,用于配置多张展示图片时的排列方式
spinProps?: object // Spin 组件属性配置,用于配置图片加载中样式
zoomRatio?: number // 每次缩放比率
minZoomScale?: number // 最小缩放比例
maxZoomScale?: number // 最大缩放比例
resetOnDbclick?: boolean // 缩放移动旋转图片后,是否可以双击还原
loop?: boolean // 是否可以循环切换图片
album?: boolean // 是否相册模式,即从一张展示图片点开相册
}
const props = withDefaults(defineProps<Props>(), {
src: undefined,
name: undefined,
width: 100,
height: 100,
bordered: true,
fit: 'contain',
preview: '预览',
spaceProps: () => ({}),
spinProps: () => ({}),
zoomRatio: 0.1,
minZoomScale: 0.1,
maxZoomScale: 10,
resetOnDbclick: true,
loop: false,
album: false
});
const images = ref<any[]>([]);
const previewRef = ref(); // DOM 引用
const previewIndex = ref(0); // 当前预览的图片索引
const showPreview = ref(false); // 是否显示预览
const rotate = ref(0); // 预览图片旋转角度
const scale = ref(1); // 缩放比例
const swapX = ref(1); // 水平镜像数值符号
const swapY = ref(1); // 垂直镜像数值符号
const sourceX = ref(0); // 拖动开始时位置
const sourceY = ref(0); // 拖动开始时位置
const dragX = ref(0); // 拖动横向距离
const dragY = ref(0); // 拖动纵向距离
const imageAmount = computed(() => {
return images.value.length;
});
const complete = ref(Array(imageAmount.value).fill(false)); // 图片是否加载完成
const loaded = ref(Array(imageAmount.value).fill(false)); // 预览图片是否加载完成
watchEffect(() => {
images.value = getImages();
});
function getImages() {
if (Array.isArray(props.src)) {
return props.src;
} else {
return [
{
src: props.src,
name: props.name
}
];
}
}
function onComplete(n: number) {
// 图片加载完成
complete.value[n] = true;
}
function onLoaded(index: number) {
// 预览图片加载完成
loaded.value[index] = true;
}
function getImageName(image: Image) {
// 从图像地址src中获取图像名称
if (image) {
if (image.name) {
return image.name;
} else {
const res = image.src.split('?')[0].split('/');
return res[res.length - 1];
}
}
}
function getImageSize(size: string | number | (string | number)[], index: number): string {
if (Array.isArray(size)) {
if (typeof size[index] === 'number') {
return `${size[index]}px`;
}
return size[index];
} else {
if (typeof size === 'number') {
return `${size}px`;
}
return size;
}
}
function onKeyboard(e: KeyboardEvent) {
if (showPreview.value && imageAmount.value > 1) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
onSwitchLeft();
}
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
onSwitchRight();
}
}
}
async function onPreview(index: number) {
scale.value = 1;
rotate.value = 0;
dragX.value = 0;
dragY.value = 0;
showPreview.value = true;
previewIndex.value = index;
await nextTick();
previewRef.value.focus();
}
defineExpose({
preview: onPreview
});
function onClose() {
// 关闭
showPreview.value = false;
}
function onZoomin() {
// 放大
if (scale.value + props.zoomRatio > props.maxZoomScale) {
scale.value = props.maxZoomScale;
} else {
scale.value = add(scale.value, props.zoomRatio);
}
}
function onZoomout() {
// 缩小
if (scale.value - props.zoomRatio < props.minZoomScale) {
scale.value = props.minZoomScale;
} else {
scale.value = add(scale.value, -props.zoomRatio);
}
}
function onResetOrigin() {
// 重置图片为初始状态
scale.value = 1;
swapX.value = 1;
swapY.value = 1;
rotate.value = 0;
dragX.value = 0;
dragY.value = 0;
}
function onClockwiseRotate() {
// 顺时针旋转
rotate.value += 90;
}
function onAnticlockwiseRotate() {
// 逆时针旋转
rotate.value -= 90;
}
function onHorizontalMirror() {
swapX.value *= -1;
}
function onVerticalMirror() {
swapY.value *= -1;
}
function onWheel(e: WheelEvent) {
// 鼠标滚轮缩放
// e.preventDefault() // 禁止浏览器捕获滑动事件
const scrollZoom = e.deltaY * props.zoomRatio * 0.1; // 滚轮的纵向滚动量
if (scale.value === props.minZoomScale && scrollZoom > 0) {
return;
}
if (scale.value === props.maxZoomScale && scrollZoom < 0) {
return;
}
if (scale.value - scrollZoom < props.minZoomScale) {
scale.value = props.minZoomScale;
} else if (scale.value - scrollZoom > props.maxZoomScale) {
scale.value = props.maxZoomScale;
} else {
scale.value = add(scale.value, -scrollZoom);
}
}
function onMouseDown(event: MouseEvent) {
// event.preventDefault() // 消除拖动元素时的阴影
const el = event.target; // 当前点击的元素
const imageRect = (el as Element).getBoundingClientRect();
const top = imageRect.top; // 图片上边缘距浏览器窗口上边界的距离
const bottom = imageRect.bottom; // 图片下边缘距浏览器窗口上边界的距离
const right = imageRect.right; // 图片右边缘距浏览器窗口左边界的距离
const left = imageRect.left; // 图片左边缘距浏览器窗口左边界的距离
const viewportWidth = window.innerWidth; // 视口宽度
const viewportHeight = window.innerHeight; // 视口高度
sourceX.value = event.clientX; // 鼠标按下时相对于视口左边缘的X坐标
sourceY.value = event.clientY; // 鼠标按下时相对于视口上边缘的Y坐标
const sourceDragX = dragX.value; // 鼠标按下时图片的X轴偏移量
const sourceDragY = dragY.value; // 鼠标按下时图片的Y轴偏移量
window.onmousemove = (e: MouseEvent) => {
// e.clientX返回事件被触发时鼠标指针相对于浏览器可视窗口的水平坐标
dragX.value = sourceDragX + e.clientX - sourceX.value;
dragY.value = sourceDragY + e.clientY - sourceY.value;
};
window.onmouseup = () => {
if (dragX.value > sourceDragX + viewportWidth - right) {
// 溢出视口右边缘
dragX.value = sourceDragX + viewportWidth - right;
}
if (dragX.value < sourceDragX - left) {
// 溢出视口左边缘
dragX.value = sourceDragX - left;
}
if (dragY.value > sourceDragY + viewportHeight - bottom) {
// 溢出视口下边缘
dragY.value = sourceDragY + viewportHeight - bottom;
}
if (dragY.value < sourceDragY - top) {
// 溢出视口上边缘
dragY.value = sourceDragY - top;
}
window.onmousemove = null;
};
}
function onSwitchLeft() {
if (props.loop) {
previewIndex.value = (previewIndex.value - 1 + imageAmount.value) % imageAmount.value;
} else {
if (previewIndex.value > 0) {
previewIndex.value--;
}
}
onResetOrigin();
}
function onSwitchRight() {
if (props.loop) {
previewIndex.value = (previewIndex.value + 1) % imageAmount.value;
} else {
if (previewIndex.value < imageAmount.value - 1) {
previewIndex.value++;
}
}
onResetOrigin();
}
</script>
<template>
<div class="m-image-wrap">
<Space gap="small" v-bind="spaceProps">
<div
v-show="!album || (album && index === 0)"
class="m-image"
:class="{ 'image-bordered': bordered, 'image-hover-mask': complete[index] }"
:style="`width: ${getImageSize(props.width, index)}; height: ${getImageSize(props.height, index)};`"
v-for="(image, index) in images"
:key="index"
>
<Spin :spinning="!complete[index]" indicator="dynamic-circle" size="small" v-bind="spinProps">
<img
class="u-image"
:style="`object-fit: ${fit};`"
@load="onComplete(index)"
:src="image.src"
:alt="getImageName(image)"
/>
</Spin>
<div class="m-image-mask" @click="onPreview(index)">
<div class="image-mask-info">
<svg
class="eye-svg"
focusable="false"
data-icon="eye"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M942.2 486.2C847.4 286.5 704.1 186 512 186c-192.2 0-335.4 100.5-430.2 300.3a60.3 60.3 0 000 51.5C176.6 737.5 319.9 838 512 838c192.2 0 335.4-100.5 430.2-300.3 7.7-16.2 7.7-35 0-51.5zM512 766c-161.3 0-279.4-81.8-362.7-254C232.6 339.8 350.7 258 512 258c161.3 0 279.4 81.8 362.7 254C791.5 684.2 673.4 766 512 766zm-4-430c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176zm0 288c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z"
></path>
</svg>
<p class="mask-pre">
<slot name="preview">{{ preview }}</slot>
</p>
</div>
</div>
</div>
</Space>
<Transition name="fade">
<div v-show="showPreview" class="m-preview-mask"></div>
</Transition>
<Transition name="zoom">
<div
v-show="showPreview"
ref="previewRef"
class="m-preview-wrap"
tabindex="-1"
@click.self="onClose"
@wheel.prevent="onWheel"
@keydown="onKeyboard"
@keydown.esc="onClose"
>
<div class="m-preview-body">
<div class="m-preview-operations">
<a
class="previe-name"
:href="images[previewIndex].src"
target="_blank"
:title="getImageName(images[previewIndex])"
>
{{ getImageName(images[previewIndex]) }}
</a>
<p class="preview-progress" v-show="Array.isArray(src)">{{ previewIndex + 1 }} / {{ imageAmount }}</p>
<div class="preview-operation" title="关闭" @click="onClose">
<svg
class="icon-svg"
focusable="false"
data-icon="close"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M563.8 512l262.5-312.9c4.4-5.2.7-13.1-6.1-13.1h-79.8c-4.7 0-9.2 2.1-12.3 5.7L511.6 449.8 295.1 191.7c-3-3.6-7.5-5.7-12.3-5.7H203c-6.8 0-10.5 7.9-6.1 13.1L459.4 512 196.9 824.9A7.95 7.95 0 00203 838h79.8c4.7 0 9.2-2.1 12.3-5.7l216.5-258.1 216.5 258.1c3 3.6 7.5 5.7 12.3 5.7h79.8c6.8 0 10.5-7.9 6.1-13.1L563.8 512z"
></path>
</svg>
</div>
<div
class="preview-operation"
:class="{ 'operation-disabled': scale === maxZoomScale }"
title="放大"
@click="onZoomin"
>
<svg
class="icon-svg"
focusable="false"
data-icon="zoom-in"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M637 443H519V309c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v134H325c-4.4 0-8 3.6-8 8v60c0 4.4 3.6 8 8 8h118v134c0 4.4 3.6 8 8 8h60c4.4 0 8-3.6 8-8V519h118c4.4 0 8-3.6 8-8v-60c0-4.4-3.6-8-8-8zm284 424L775 721c122.1-148.9 113.6-369.5-26-509-148-148.1-388.4-148.1-537 0-148.1 148.6-148.1 389 0 537 139.5 139.6 360.1 148.1 509 26l146 146c3.2 2.8 8.3 2.8 11 0l43-43c2.8-2.7 2.8-7.8 0-11zM696 696c-118.8 118.7-311.2 118.7-430 0-118.7-118.8-118.7-311.2 0-430 118.8-118.7 311.2-118.7 430 0 118.7 118.8 118.7 311.2 0 430z"
></path>
</svg>
</div>
<div
class="preview-operation"
:class="{ 'operation-disabled': scale === minZoomScale }"
title="缩小"
@click="onZoomout"
>
<svg
class="icon-svg"
focusable="false"
data-icon="zoom-out"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M637 443H325c-4.4 0-8 3.6-8 8v60c0 4.4 3.6 8 8 8h312c4.4 0 8-3.6 8-8v-60c0-4.4-3.6-8-8-8zm284 424L775 721c122.1-148.9 113.6-369.5-26-509-148-148.1-388.4-148.1-537 0-148.1 148.6-148.1 389 0 537 139.5 139.6 360.1 148.1 509 26l146 146c3.2 2.8 8.3 2.8 11 0l43-43c2.8-2.7 2.8-7.8 0-11zM696 696c-118.8 118.7-311.2 118.7-430 0-118.7-118.8-118.7-311.2 0-430 118.8-118.7 311.2-118.7 430 0 118.7 118.8 118.7 311.2 0 430z"
></path>
</svg>
</div>
<div class="preview-operation" title="还原" @click="onResetOrigin">
<svg
class="icon-svg"
focusable="false"
data-icon="expand"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M342 88H120c-17.7 0-32 14.3-32 32v224c0 8.8 7.2 16 16 16h48c8.8 0 16-7.2 16-16V168h174c8.8 0 16-7.2 16-16v-48c0-8.8-7.2-16-16-16zm578 576h-48c-8.8 0-16 7.2-16 16v176H682c-8.8 0-16 7.2-16 16v48c0 8.8 7.2 16 16 16h222c17.7 0 32-14.3 32-32V680c0-8.8-7.2-16-16-16zM342 856H168V680c0-8.8-7.2-16-16-16h-48c-8.8 0-16 7.2-16 16v224c0 17.7 14.3 32 32 32h222c8.8 0 16-7.2 16-16v-48c0-8.8-7.2-16-16-16zM904 88H682c-8.8 0-16 7.2-16 16v48c0 8.8 7.2 16 16 16h174v176c0 8.8 7.2 16 16 16h48c8.8 0 16-7.2 16-16V120c0-17.7-14.3-32-32-32z"
></path>
</svg>
</div>
<div class="preview-operation" title="向右旋转" @click="onClockwiseRotate">
<svg
class="icon-svg"
focusable="false"
data-icon="rotate-right"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M480.5 251.2c13-1.6 25.9-2.4 38.8-2.5v63.9c0 6.5 7.5 10.1 12.6 6.1L660 217.6c4-3.2 4-9.2 0-12.3l-128-101c-5.1-4-12.6-.4-12.6 6.1l-.2 64c-118.6.5-235.8 53.4-314.6 154.2A399.75 399.75 0 00123.5 631h74.9c-.9-5.3-1.7-10.7-2.4-16.1-5.1-42.1-2.1-84.1 8.9-124.8 11.4-42.2 31-81.1 58.1-115.8 27.2-34.7 60.3-63.2 98.4-84.3 37-20.6 76.9-33.6 119.1-38.8z"
></path>
<path
d="M880 418H352c-17.7 0-32 14.3-32 32v414c0 17.7 14.3 32 32 32h528c17.7 0 32-14.3 32-32V450c0-17.7-14.3-32-32-32zm-44 402H396V494h440v326z"
></path>
</svg>
</div>
<div class="preview-operation" title="向左旋转" @click="onAnticlockwiseRotate">
<svg
class="icon-svg"
focusable="false"
data-icon="rotate-left"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M672 418H144c-17.7 0-32 14.3-32 32v414c0 17.7 14.3 32 32 32h528c17.7 0 32-14.3 32-32V450c0-17.7-14.3-32-32-32zm-44 402H188V494h440v326z"
></path>
<path
d="M819.3 328.5c-78.8-100.7-196-153.6-314.6-154.2l-.2-64c0-6.5-7.6-10.1-12.6-6.1l-128 101c-4 3.1-3.9 9.1 0 12.3L492 318.6c5.1 4 12.7.4 12.6-6.1v-63.9c12.9.1 25.9.9 38.8 2.5 42.1 5.2 82.1 18.2 119 38.7 38.1 21.2 71.2 49.7 98.4 84.3 27.1 34.7 46.7 73.7 58.1 115.8a325.95 325.95 0 016.5 140.9h74.9c14.8-103.6-11.3-213-81-302.3z"
></path>
</svg>
</div>
<div class="preview-operation" title="水平镜像" @click="onHorizontalMirror">
<svg
class="icon-svg"
focusable="false"
data-icon="swap"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M847.9 592H152c-4.4 0-8 3.6-8 8v60c0 4.4 3.6 8 8 8h605.2L612.9 851c-4.1 5.2-.4 13 6.3 13h72.5c4.9 0 9.5-2.2 12.6-6.1l168.8-214.1c16.5-21 1.6-51.8-25.2-51.8zM872 356H266.8l144.3-183c4.1-5.2.4-13-6.3-13h-72.5c-4.9 0-9.5 2.2-12.6 6.1L150.9 380.2c-16.5 21-1.6 51.8 25.1 51.8h696c4.4 0 8-3.6 8-8v-60c0-4.4-3.6-8-8-8z"
></path>
</svg>
</div>
<div class="preview-operation" title="垂直镜像" @click="onVerticalMirror">
<svg
class="icon-svg"
style="transform: rotate(90deg)"
focusable="false"
data-icon="swap"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M847.9 592H152c-4.4 0-8 3.6-8 8v60c0 4.4 3.6 8 8 8h605.2L612.9 851c-4.1 5.2-.4 13 6.3 13h72.5c4.9 0 9.5-2.2 12.6-6.1l168.8-214.1c16.5-21 1.6-51.8-25.2-51.8zM872 356H266.8l144.3-183c4.1-5.2.4-13-6.3-13h-72.5c-4.9 0-9.5 2.2-12.6 6.1L150.9 380.2c-16.5 21-1.6 51.8 25.1 51.8h696c4.4 0 8-3.6 8-8v-60c0-4.4-3.6-8-8-8z"
></path>
</svg>
</div>
</div>
<div
class="m-preview-image"
:style="`transform: translate3d(${dragX}px, ${dragY}px, 0px);`"
v-show="previewIndex === index"
v-for="(image, index) in images"
:key="index"
>
<img
class="preview-image"
:style="`transform: scale3d(${swapX * scale}, ${swapY * scale}, 1) rotate(${rotate}deg);`"
:src="image.src"
:alt="getImageName(image)"
@mousedown.prevent="onMouseDown($event)"
@load="onLoaded(index)"
@dblclick="resetOnDbclick ? onResetOrigin() : () => false"
/>
</div>
<template v-if="imageAmount > 1">
<div class="switch-left" :class="{ 'switch-disabled': previewIndex === 0 && !loop }" @click="onSwitchLeft">
<svg
class="switch-svg"
focusable="false"
data-icon="left"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M724 218.3V141c0-6.7-7.7-10.4-12.9-6.3L260.3 486.8a31.86 31.86 0 000 50.3l450.8 352.1c5.3 4.1 12.9.4 12.9-6.3v-77.3c0-4.9-2.3-9.6-6.1-12.6l-360-281 360-281.1c3.8-3 6.1-7.7 6.1-12.6z"
></path>
</svg>
</div>
<div
class="switch-right"
:class="{ 'switch-disabled': previewIndex === imageAmount - 1 && !loop }"
@click="onSwitchRight"
>
<svg
class="switch-svg"
focusable="false"
data-icon="right"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M765.7 486.8L314.9 134.7A7.97 7.97 0 00302 141v77.3c0 4.9 2.3 9.6 6.1 12.6l360 281.1-360 281.1c-3.9 3-6.1 7.7-6.1 12.6V883c0 6.7 7.7 10.4 12.9 6.3l450.8-352.1a31.96 31.96 0 000-50.4z"
></path>
</svg>
</div>
</template>
</div>
</div>
</Transition>
</div>
</template>
<style lang="less" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s linear;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.zoom-enter-active {
transition: opacity 0.3s cubic-bezier(0.08, 0.82, 0.17, 1),
transform 0.3s cubic-bezier(0.08, 0.82, 0.17, 1);
}
.zoom-leave-active {
transition: opacity 0.2s cubic-bezier(0.78, 0.14, 0.15, 0.86),
transform 0.2s cubic-bezier(0.78, 0.14, 0.15, 0.86);
}
.zoom-enter-from,
.zoom-leave-to {
opacity: 0;
transform: scale(0.2);
}
.m-image-wrap {
display: inline-block;
.image-hover-mask {
&:hover {
.m-image-mask {
opacity: 1;
pointer-events: auto;
}
}
}
.m-image {
position: relative;
display: inline-block;
vertical-align: top;
border-radius: 8px;
overflow: hidden;
.u-image {
display: inline-block;
width: 100%;
height: 100%;
vertical-align: bottom;
}
.m-image-mask {
// top right bottom left 简写为 inset: 0
// insert 无论元素的书写模式、行内方向和文本朝向如何,其所定义的都不是逻辑偏移而是实体偏移
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: rgba(0, 0, 0, 0.5);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
.image-mask-info {
display: inline-flex;
align-items: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: 0 4px;
.eye-svg {
margin-right: 4px;
font-size: 14px;
color: #fff;
fill: currentColor;
}
.mask-pre {
display: inline-block;
color: #fff;
}
}
}
}
.image-bordered {
border: 1px solid #d9d9d9;
}
.m-preview-mask {
position: fixed;
inset: 0;
z-index: 1000;
height: 100%;
background-color: rgba(0, 0, 0, 0.45);
}
.m-preview-wrap {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
overflow: auto;
outline: none;
z-index: 1080;
height: 100%;
text-align: center;
.m-preview-body {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
.m-preview-operations {
position: fixed;
width: 100%;
z-index: 9;
display: flex;
flex-direction: row-reverse;
align-items: center;
background: rgba(0, 0, 0, 0.1);
height: 42px;
pointer-events: auto;
.previe-name {
position: absolute;
left: 12px;
font-size: 14px;
color: rgb(255, 255, 255);
line-height: 1.57;
max-width: calc(50% - 60px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.3s;
&:hover {
color: #fff;
}
}
.preview-progress {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 14px;
color: rgb(255, 255, 255);
line-height: 1.57;
}
.preview-operation {
display: flex;
justify-content: center;
align-items: center;
width: 42px;
height: 42px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
&:not(:last-child) {
margin-left: 12px;
}
&:hover {
background: rgba(0, 0, 0, 0.25);
}
.icon-svg {
font-size: 18px;
color: #fff;
fill: currentColor;
}
}
.operation-disabled {
color: rgba(255, 255, 255, 0.25);
pointer-events: none;
.icon-svg {
color: rgba(255, 255, 255, 0.25);
}
}
}
.m-preview-image {
position: absolute;
z-index: 3;
inset: 0;
transition: transform 0.3s cubic-bezier(0.215, 0.61, 0.355, 1) 0s;
display: flex;
justify-content: center;
align-items: center;
.preview-image {
display: inline-block;
vertical-align: middle;
max-width: 100%;
max-height: 100vh;
cursor: grab;
transition: transform 0.3s cubic-bezier(0.215, 0.61, 0.355, 1) 0s;
user-select: none;
pointer-events: auto;
}
}
.switch-left {
left: 12px;
position: fixed;
top: 50%;
z-index: 1081;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin-top: -20px;
color: rgb(255, 255, 255);
background: rgba(0, 0, 0, 0.1);
border-radius: 50%;
transform: translateY(-50%);
cursor: pointer;
transition: all 0.3s;
pointer-events: auto;
user-select: none;
&:hover {
background: rgba(0, 0, 0, 0.2);
}
.switch-svg {
font-size: 18px;
color: #fff;
fill: currentColor;
}
}
.switch-right {
right: 12px;
position: fixed;
top: 50%;
z-index: 1081;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin-top: -20px;
color: rgb(255, 255, 255);
background: rgba(0, 0, 0, 0.1);
border-radius: 50%;
transform: translateY(-50%);
cursor: pointer;
transition: all 0.3s;
pointer-events: auto;
&:hover {
background: rgba(0, 0, 0, 0.2);
}
.switch-svg {
font-size: 18px;
color: #fff;
fill: currentColor;
}
}
.switch-disabled {
color: rgba(255, 255, 255, 0.25);
background: transparent;
cursor: not-allowed;
&:hover {
background: transparent;
}
.switch-svg {
color: rgba(255, 255, 255, 0.25);
}
}
}
}
}
</style>

View File

@@ -1,425 +0,0 @@
<script setup lang="ts">
import {computed, nextTick, ref} from 'vue';
import {useSlotsExist} from '../Utils';
interface Props {
width?: string | number // 输入框宽度,单位 px
size?: 'small' | 'middle' | 'large' // 输入框大小
addonBefore?: string // 设置前置标签 string | slot
addonAfter?: string // 设置后置标签 string | slot
prefix?: string // 前缀图标 string | slot
suffix?: string // 后缀图标 string | slot
allowClear?: boolean // 可以点击清除图标删除内容
password?: boolean // 是否启用密码框
disabled?: boolean // 是否禁用
placeholder?: string // 文本输入的占位符
maxLength?: number // 最大长度
showCount?: boolean // 是否展示字数
value?: string // (v-model) 输入框内容
valueModifiers?: object // 用于访问组件的 v-model 上添加的修饰符
}
const props = withDefaults(defineProps<Props>(), {
width: '100%',
size: 'middle',
addonBefore: undefined,
addonAfter: undefined,
prefix: undefined,
suffix: undefined,
allowClear: false,
password: false,
disabled: false,
placeholder: undefined,
maxlength: undefined,
showCount: false,
value: undefined,
valueModifiers: () => ({})
});
const inputRef = ref(); // input 元素引用
const showPassword = ref(false);
const emits = defineEmits(['update:value', 'change', 'enter']);
const slotsExist = useSlotsExist(['prefix', 'suffix', 'addonBefore', 'addonAfter']);
const inputWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`;
}
return props.width;
});
const showClear = computed(() => {
return !props.disabled && props.allowClear;
});
const showCountNum = computed(() => {
if (props.maxLength) {
return `${(props.value ? props.value.length : 0)} / ${props.maxLength}`;
}
return props.value ? props.value.length : 0;
});
const showPrefix = computed(() => {
return slotsExist.prefix || props.prefix;
});
const showSuffix = computed(() => {
return slotsExist.suffix || props.suffix;
});
const showInputSuffix = computed(() => {
return showClear.value || props.password || props.showCount || showSuffix.value;
});
const showBefore = computed(() => {
return slotsExist.addonBefore || props.addonBefore;
});
const showAfter = computed(() => {
return slotsExist.addonAfter || props.addonAfter;
});
const lazyInput = computed(() => {
return 'lazy.ts' in props.valueModifiers;
});
function onInput(e: InputEvent) {
if (!lazyInput.value) {
emits('update:value', (e.target as HTMLInputElement).value);
emits('change', e);
}
}
function onChange(e: InputEvent) {
if (lazyInput.value) {
emits('update:value', (e.target as HTMLInputElement).value);
emits('change', e);
}
}
function onKeyboard(e: KeyboardEvent) {
emits('enter', e);
if (lazyInput.value) {
inputRef.value.blur();
nextTick(() => {
inputRef.value.focus();
});
}
}
function onClear() {
emits('update:value', '');
inputRef.value.focus();
}
function onPassword() {
showPassword.value = !showPassword.value;
}
</script>
<template>
<div class="m-input-wrap" :style="`width: ${inputWidth};`">
<span v-if="showBefore" class="m-addon" :class="{ 'addon-before': showBefore }">
<slot name="addonBefore">{{ addonBefore }}</slot>
</span>
<div
tabindex="1"
class="m-input"
:class="[
`input-${size}`,
{
'input-before': showBefore,
'input-after': showAfter,
'input-disabled': disabled
}
]"
>
<span v-if="showPrefix" class="input-prefix">
<slot name="prefix">{{ prefix }}</slot>
</span>
<Input
:ref="inputRef"
class="u-input"
:type="password && !showPassword ? 'password' : 'text'"
:value="value"
:placeholder="placeholder"
:maxLength="maxLength"
:disabled="disabled"
@input="onInput"
@change="onChange"
@keydown.enter.prevent="onKeyboard"
/>
<span v-if="showInputSuffix" class="input-suffix">
<span v-if="showClear" class="m-actions" :class="{ 'clear-hidden': !value }" @click="onClear">
<svg
class="clear-svg"
focusable="false"
data-icon="close-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm165.4 618.2l-66-.3L512 563.4l-99.3 118.4-66.1.3c-4.4 0-8-3.5-8-8 0-1.9.7-3.7 1.9-5.2l130.1-155L340.5 359a8.32 8.32 0 01-1.9-5.2c0-4.4 3.6-8 8-8l66.1.3L512 464.6l99.3-118.4 66-.3c4.4 0 8 3.5 8 8 0 1.9-.7 3.7-1.9 5.2L553.5 514l130 155c1.2 1.5 1.9 3.3 1.9 5.2 0 4.4-3.6 8-8 8z"
></path>
</svg>
</span>
<span v-if="password" class="m-actions" @click="onPassword">
<svg
v-show="showPassword"
class="eye-svg"
focusable="false"
data-icon="eye"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M942.2 486.2C847.4 286.5 704.1 186 512 186c-192.2 0-335.4 100.5-430.2 300.3a60.3 60.3 0 000 51.5C176.6 737.5 319.9 838 512 838c192.2 0 335.4-100.5 430.2-300.3 7.7-16.2 7.7-35 0-51.5zM512 766c-161.3 0-279.4-81.8-362.7-254C232.6 339.8 350.7 258 512 258c161.3 0 279.4 81.8 362.7 254C791.5 684.2 673.4 766 512 766zm-4-430c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176zm0 288c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z"
></path>
</svg>
<svg
v-show="!showPassword"
class="eye-svg"
focusable="false"
data-icon="eye-invisible"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M942.2 486.2Q889.47 375.11 816.7 305l-50.88 50.88C807.31 395.53 843.45 447.4 874.7 512 791.5 684.2 673.4 766 512 766q-72.67 0-133.87-22.38L323 798.75Q408 838 512 838q288.3 0 430.2-300.3a60.29 60.29 0 000-51.5zm-63.57-320.64L836 122.88a8 8 0 00-11.32 0L715.31 232.2Q624.86 186 512 186q-288.3 0-430.2 300.3a60.3 60.3 0 000 51.5q56.69 119.4 136.5 191.41L112.48 835a8 8 0 000 11.31L155.17 889a8 8 0 0011.31 0l712.15-712.12a8 8 0 000-11.32zM149.3 512C232.6 339.8 350.7 258 512 258c54.54 0 104.13 9.36 149.12 28.39l-70.3 70.3a176 176 0 00-238.13 238.13l-83.42 83.42C223.1 637.49 183.3 582.28 149.3 512zm246.7 0a112.11 112.11 0 01146.2-106.69L401.31 546.2A112 112 0 01396 512z"
></path>
<path
d="M508 624c-3.46 0-6.87-.16-10.25-.47l-52.82 52.82a176.09 176.09 0 00227.42-227.42l-52.82 52.82c.31 3.38.47 6.79.47 10.25a111.94 111.94 0 01-112 112z"
></path>
</svg>
</span>
<span v-if="showCount" class="input-count">{{ showCountNum }}</span>
<span v-if="showSuffix" class="m-suffix">
<slot name="suffix">{{ suffix }}</slot>
</span>
</span>
</div>
<span v-if="showAfter" class="m-addon" :class="{ 'addon-after': showAfter }">
<slot name="addonAfter">{{ addonAfter }}</slot>
</span>
</div>
</template>
<style lang="less" scoped>
.m-input-wrap {
width: 100%;
text-align: start;
vertical-align: top;
position: relative;
display: inline-table;
border-collapse: separate;
border-spacing: 0;
.m-addon {
position: relative;
padding: 0 11px;
color: rgba(0, 0, 0, 0.88);
font-weight: normal;
font-size: 14px;
text-align: center;
background-color: rgba(0, 0, 0, 0.02);
border: 1px solid #d9d9d9;
border-radius: 6px;
line-height: 1;
display: table-cell;
width: 1px;
white-space: nowrap;
vertical-align: middle;
transition: all 0.3s;
:deep(svg) {
fill: currentColor;
}
}
.addon-before {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: 0;
}
.addon-after {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 0;
}
.m-input {
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
position: relative;
display: inline-flex;
width: 100%;
min-width: 0;
background-color: #ffffff;
border: 1px solid #d9d9d9;
transition: all 0.2s;
&:hover {
border-color: #4096ff;
border-right-width: 1px;
z-index: 1;
}
&:focus-within {
border-color: #4096ff;
box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
border-right-width: 1px;
outline: 0;
}
.input-prefix {
margin-right: 4px;
display: flex;
flex: none;
align-items: center;
:deep(svg) {
fill: currentColor;
}
}
.u-input {
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
position: relative;
display: inline-block;
width: 100%;
min-width: 0;
background-color: #ffffff;
border: none;
outline: none;
text-overflow: ellipsis;
transition: all 0.2s;
}
input::-webkit-input-placeholder {
color: rgba(0, 0, 0, 0.25);
}
input:-moz-placeholder {
color: rgba(0, 0, 0, 0.25);
}
input::-moz-placeholder {
color: rgba(0, 0, 0, 0.25);
}
input:-ms-input-placeholder {
color: rgba(0, 0, 0, 0.25);
}
.input-suffix {
margin-left: 4px;
display: flex;
flex: none;
gap: 8px;
align-items: center;
.m-actions {
display: inline-flex;
align-items: center;
cursor: pointer;
.clear-svg {
display: inline-block;
font-size: 12px;
color: rgba(0, 0, 0, 0.25);
fill: currentColor;
transition: color 0.3s;
&:hover {
color: rgba(0, 0, 0, 0.45);
}
}
.eye-svg {
display: inline-block;
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
fill: currentColor;
transition: color 0.3s;
&:hover {
color: rgba(0, 0, 0, 0.85);
}
}
}
.clear-hidden {
visibility: hidden;
}
.input-count {
color: rgba(0, 0, 0, 0.45);
}
.m-suffix {
display: flex;
flex: none;
align-items: center;
:deep(svg) {
fill: currentColor;
}
}
}
}
.input-small {
padding: 0 7px;
border-radius: 4px;
}
.input-middle {
padding: 4px 11px;
border-radius: 6px;
}
.input-large {
padding: 7px 11px;
font-size: 16px;
line-height: 1.5;
border-radius: 8px;
.u-input {
font-size: 16px;
line-height: 1.5;
}
}
.input-before {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.input-after {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.input-disabled {
color: rgba(0, 0, 0, 0.25);
background-color: rgba(0, 0, 0, 0.04);
cursor: not-allowed;
&:hover {
border-color: #d9d9d9;
}
&:focus-within {
border-color: #d9d9d9;
box-shadow: none;
}
.u-input {
color: rgba(0, 0, 0, 0.25);
background-color: transparent;
cursor: not-allowed;
}
}
}
</style>

View File

@@ -1,435 +0,0 @@
<script setup lang="ts">
import {computed, nextTick, ref} from 'vue';
import {useSlotsExist} from '../Utils';
interface Props {
width?: string | number // 搜索框宽度,单位 px
icon?: boolean // 搜索图标 boolean | slot
search?: string // 搜索按钮,默认时为搜索图标 string | slot
searchProps?: object // 设置搜索按钮的属性,参考 Button Props
size?: 'small' | 'middle' | 'large' // 搜索框大小
allowClear?: boolean // 可以点击清除图标删除搜索框内容
addonBefore?: string // 设置前置标签 string | slot
prefix?: string // 前缀图标 string | slot
suffix?: string // 后缀图标 string | slot
loading?: boolean // 是否搜索中
disabled?: boolean // 是否禁用
placeholder?: string // 搜索框输入的占位符
maxlength?: number // 文本最大长度
showCount?: boolean // 是否展示字数
value?: string // (v-model) 搜索框内容
valueModifiers?: object // 用于访问组件的 v-model 上添加的修饰符
}
const props = withDefaults(defineProps<Props>(), {
width: '100%',
icon: true,
search: undefined,
searchProps: () => ({}),
size: 'middle',
addonBefore: undefined,
prefix: undefined,
suffix: undefined,
allowClear: false,
loading: false,
disabled: false,
placeholder: undefined,
maxlength: undefined,
showCount: false,
value: undefined,
valueModifiers: () => ({})
});
const inputRef = ref();
const slotsExist = useSlotsExist(['prefix', 'suffix', 'addonBefore']);
const emits = defineEmits(['update:value', 'change', 'search']);
const inputSearchWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`;
}
return props.width;
});
const showClear = computed(() => {
return !props.disabled && props.allowClear;
});
const showCountNum = computed(() => {
if (props.maxlength) {
return (props.value ? props.value.length : 0) + ' / ' + props.maxlength;
}
return props.value ? props.value.length : 0;
});
const showPrefix = computed(() => {
return slotsExist.prefix || props.prefix;
});
const showSuffix = computed(() => {
return slotsExist.suffix || props.suffix;
});
const showInputSuffix = computed(() => {
return showClear.value || props.showCount || showSuffix.value;
});
const showBefore = computed(() => {
return slotsExist.addonBefore || props.addonBefore;
});
const lazyInput = computed(() => {
return 'lazy.ts' in props.valueModifiers;
});
function onInput(e: InputEvent) {
if (!lazyInput.value) {
emits('update:value', (e.target as HTMLInputElement).value);
emits('change', e);
}
}
function onChange(e: InputEvent) {
if (lazyInput.value) {
emits('update:value', (e.target as HTMLInputElement).value);
emits('change', e);
}
}
function onClear() {
emits('update:value', '');
inputRef.value.focus();
}
async function onInputSearch(_e: KeyboardEvent) {
if (!lazyInput.value) {
onSearch();
} else {
if (lazyInput.value) {
inputRef.value.blur();
await nextTick();
inputRef.value.focus();
}
emits('search', props.value);
}
}
function onSearch() {
emits('search', props.value);
}
</script>
<template>
<div class="m-input-search-wrap" :style="`width: ${inputSearchWidth};`">
<span v-if="showBefore" class="m-addon-before" :class="`addon-before-${size}`">
<slot name="addonBefore">{{ addonBefore }}</slot>
</span>
<div
tabindex="1"
class="m-input-search"
:class="[
`input-search-${size}`,
{
'input-search-before': showBefore,
'input-search-disabled': disabled
}
]"
>
<span v-if="showPrefix" class="m-prefix">
<slot name="prefix">{{ prefix }}</slot>
</span>
<Input
ref="inputRef"
class="input-search"
type="text"
:value="value"
:placeholder="placeholder"
:maxlength="maxlength"
:disabled="disabled"
@input="onInput"
@change="onChange"
@keydown.enter.prevent="onInputSearch"
/>
<span v-if="showInputSuffix" class="input-search-suffix">
<span v-if="showClear" class="m-clear" :class="{ 'clear-hidden': !value }" @click="onClear">
<svg
class="clear-svg"
focusable="false"
data-icon="close-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm165.4 618.2l-66-.3L512 563.4l-99.3 118.4-66.1.3c-4.4 0-8-3.5-8-8 0-1.9.7-3.7 1.9-5.2l130.1-155L340.5 359a8.32 8.32 0 01-1.9-5.2c0-4.4 3.6-8 8-8l66.1.3L512 464.6l99.3-118.4 66-.3c4.4 0 8 3.5 8 8 0 1.9-.7 3.7-1.9 5.2L553.5 514l130 155c1.2 1.5 1.9 3.3 1.9 5.2 0 4.4-3.6 8-8 8z"
></path>
</svg>
</span>
<span v-if="showCount" class="input-search-count">{{ showCountNum }}</span>
<span v-if="showSuffix" class="m-suffix">
<slot name="suffix">{{ suffix }}</slot>
</span>
</span>
</div>
<span class="m-search-button" @click="onSearch" @keydown.enter.prevent="onSearch">
<slot name="search">
<Button class="search-btn" :size="size" :disabled="disabled" :loading="loading" v-bind="searchProps">
<template v-if="icon" #icon>
<svg
focusable="false"
data-icon="search"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M909.6 854.5L649.9 594.8C690.2 542.7 712 479 712 412c0-80.2-31.3-155.4-87.9-212.1-56.6-56.7-132-87.9-212.1-87.9s-155.5 31.3-212.1 87.9C143.2 256.5 112 331.8 112 412c0 80.1 31.3 155.5 87.9 212.1C256.5 680.8 331.8 712 412 712c67 0 130.6-21.8 182.7-62l259.7 259.6a8.2 8.2 0 0011.6 0l43.6-43.5a8.2 8.2 0 000-11.6zM570.4 570.4C528 612.7 471.8 636 412 636s-116-23.3-158.4-65.6C211.3 528 188 471.8 188 412s23.3-116.1 65.6-158.4C296 211.3 352.2 188 412 188s116.1 23.2 158.4 65.6S636 352.2 636 412s-23.3 116.1-65.6 158.4z"
></path>
</svg>
</template>
{{ search }}
</Button>
</slot>
</span>
</div>
</template>
<style lang="less" scoped>
.m-input-search-wrap {
width: 100%;
position: relative;
display: inline-flex;
align-items: center;
.m-addon-before {
display: inline-flex;
justify-content: center;
align-items: center;
position: relative;
padding: 0 11px;
color: rgba(0, 0, 0, 0.88);
font-weight: normal;
font-size: 14px;
line-height: 1.5714285714285714;
text-align: center;
background-color: rgba(0, 0, 0, 0.02);
border: 1px solid #d9d9d9;
border-radius: 6px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: 0;
transition: all 0.3s;
:deep(svg) {
fill: currentColor;
}
}
.addon-before-small {
height: 24px;
}
.addon-before-middle {
height: 32px;
}
.addon-before-small {
height: 40px;
}
.m-input-search {
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
position: relative;
display: inline-flex;
width: 100%;
min-width: 0;
background-color: #ffffff;
border: 1px solid #d9d9d9;
transition: all 0.2s;
&:hover {
border-color: #4096ff;
border-right-width: 1px;
z-index: 1;
}
&:focus-within {
border-color: #4096ff;
box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
border-right-width: 1px;
outline: 0;
z-index: 1;
}
.m-prefix {
margin-right: 4px;
display: flex;
flex: none;
align-items: center;
:deep(svg) {
fill: currentColor;
}
}
.input-search {
font-size: 14px;
color: inherit;
line-height: 1.5714285714285714;
position: relative;
display: inline-block;
width: 100%;
min-width: 0;
background-color: #ffffff;
border: none;
outline: none;
text-overflow: ellipsis;
transition: all 0.2s;
}
input::-webkit-input-placeholder {
color: rgba(0, 0, 0, 0.25);
}
input:-moz-placeholder {
color: rgba(0, 0, 0, 0.25);
}
input::-moz-placeholder {
color: rgba(0, 0, 0, 0.25);
}
input:-ms-input-placeholder {
color: rgba(0, 0, 0, 0.25);
}
.input-search-suffix {
margin-left: 4px;
display: flex;
flex: none;
gap: 8px;
align-items: center;
.m-clear {
display: inline-flex;
justify-content: center;
align-items: center;
cursor: pointer;
.clear-svg {
display: inline-block;
font-size: 12px;
color: rgba(0, 0, 0, 0.25);
fill: currentColor;
transition: color 0.3s;
&:hover {
color: rgba(0, 0, 0, 0.45);
}
}
}
.clear-hidden {
visibility: hidden;
}
.input-search-count {
color: rgba(0, 0, 0, 0.45);
}
.m-suffix {
display: flex;
flex: none;
align-items: center;
:deep(svg) {
fill: currentColor;
}
}
}
}
.input-search-small {
height: 24px;
padding: 0 7px;
border-radius: 4px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.input-search-middle {
height: 32px;
padding: 4px 11px;
border-radius: 6px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.input-search-large {
height: 40px;
padding: 7px 11px;
font-size: 16px;
line-height: 1.5;
border-radius: 8px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
.input-search {
font-size: 16px;
line-height: 1.5;
}
}
.input-search-before {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.input-search-disabled {
color: rgba(0, 0, 0, 0.25);
background-color: rgba(0, 0, 0, 0.04);
cursor: not-allowed;
&:hover {
border-color: #d9d9d9;
}
&:focus-within {
border-color: #d9d9d9;
box-shadow: none;
}
.input-search {
color: rgba(0, 0, 0, 0.25);
background-color: transparent;
cursor: not-allowed;
}
}
.m-search-button {
position: relative;
left: -1px;
border-left: 0;
color: rgba(0, 0, 0, 0.88);
font-weight: normal;
font-size: 14px;
text-align: center;
background-color: rgba(0, 0, 0, 0.02);
border-top-left-radius: 0;
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
border-bottom-left-radius: 0;
transition: all 0.3s;
line-height: 1;
:deep(.m-btn) {
padding-top: 0;
padding-bottom: 0;
border-top-left-radius: 0;
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
border-bottom-left-radius: 0;
&:not(.btn-primary):not(.btn-danger):not(.btn-link):not(.btn-disabled) {
color: rgba(0, 0, 0, 0.45);
}
}
}
}
</style>

View File

@@ -1,197 +0,0 @@
<script setup lang="ts">
import {computed} from 'vue';
import Spin from '../Spin/Spin.vue';
import Empty from '../Empty/Empty.vue';
import Pagination from '../Pagination/Pagination.vue';
import {useSlotsExist} from '../Utils';
interface Props {
bordered?: boolean // 是否展示边框
vertical?: boolean // 是否使用竖直样式
split?: boolean // 是否展示分割线
size?: 'small' | 'middle' | 'large' // 列表尺寸
loading?: boolean // 是否加载中
hoverable?: boolean // 是否显示悬浮样式
header?: string // 列表头部 string | slot
footer?: string // 列表底部 string | slot
spinProps?: object // Spin 组件属性配置,参考 Spin Props用于配置列表加载中样式
emptyProps?: object // Empty 组件属性配置,参考 Empty Props用于配置暂无数据样式
showPagination?: boolean // 是否显示分页
pagination?: object // Pagination 组件属性配置,参考 Pagination Props用于配置分页功能
}
const props = withDefaults(defineProps<Props>(), {
bordered: false,
vertical: false,
split: true,
size: 'middle',
loading: false,
hoverable: false,
header: undefined,
footer: undefined,
spinProps: () => ({}),
emptyProps: () => ({}),
showPagination: false,
pagination: () => ({})
});
const slotsExist = useSlotsExist(['header', 'default', 'footer']);
const showHeader = computed(() => {
return slotsExist.header || props.header;
});
const showFooter = computed(() => {
return slotsExist.footer || props.footer;
});
</script>
<template>
<Spin size="small" :spinning="loading" v-bind="spinProps">
<div
class="m-list"
:class="{
'list-bordered': bordered,
'list-vertical': vertical,
'list-split': split,
'list-small': size === 'small',
'list-large': size === 'large',
'list-hoverable': hoverable
}"
>
<div v-if="showHeader" class="list-header">
<slot name="header">{{ header }}</slot>
</div>
<slot v-if="slotsExist.default"></slot>
<div v-else class="list-empty">
<Empty image="outlined" v-bind="emptyProps"/>
</div>
<div v-if="showFooter" class="list-footer">
<slot name="footer">{{ footer }}</slot>
</div>
<div v-if="showPagination" class="list-pagination">
<Pagination placement="right" v-bind="pagination"/>
</div>
</div>
</Spin>
</template>
<style lang="less" scoped>
.m-list {
margin: 0;
position: relative;
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
line-height: 1.5714285714285714;
.list-header,
.list-footer {
background: transparent;
padding: 12px 0;
transition: all 0.3s;
}
.list-empty {
padding: 16px;
}
.list-pagination {
margin-top: 24px;
}
}
.list-bordered {
border: 1px solid #d9d9d9;
border-radius: 8px;
.list-header,
:deep(.m-list-item),
.list-footer {
padding-inline: 24px;
}
.list-pagination {
margin: 16px 24px;
}
}
.list-vertical {
:deep(.m-list-item) {
align-items: initial;
.m-list-item-main {
display: block;
.m-list-item-meta {
margin-bottom: 16px;
.m-list-item-content {
.list-item-title {
margin-bottom: 12px;
color: rgba(0, 0, 0, 0.88);
font-size: 16px;
font-weight: 700;
line-height: 1.5;
}
}
}
.list-item-actions {
margin-top: 16px;
margin-left: auto;
& > * {
padding: 0 16px;
&:first-child {
padding-left: 0;
}
}
}
}
}
}
.list-split {
.list-header {
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
}
:deep(.m-list-item) {
&:not(:last-child) {
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
}
}
}
.list-small {
:deep(.m-list-item) {
padding: 8px 16px;
}
}
.list-bordered.list-small {
.list-header,
:deep(.m-list-item),
.list-footer {
padding: 8px 16px;
}
}
.list-large {
:deep(.m-list-item) {
padding: 16px 24px;
}
}
.list-bordered.list-large {
.list-header,
:deep(.m-list-item),
.list-footer {
padding: 16px 24px;
}
}
.list-hoverable {
:deep(.m-list-item) {
&:hover {
background-color: rgba(0, 0, 0, 0.02);
}
}
}
</style>

View File

@@ -1,169 +0,0 @@
<script setup lang="ts">
import type {CSSProperties} from 'vue';
import {nextTick, ref} from 'vue';
interface Props {
containerClass?: string // 加载条容器的类名
containerStyle?: CSSProperties // 加载条容器的样式
loadingBarSize?: number // 加载条大小,单位 px
colorLoading?: string // 加载中颜色
colorFinish?: string // 加载完成颜色
colorError?: string // 加载错误颜色
to?: string | HTMLElement // 加载条的挂载位置,可选:元素标签名(例如 body或者元素本身
}
withDefaults(defineProps<Props>(), {
containerClass: undefined,
containerStyle: () => ({}),
loadingBarSize: 2,
colorLoading: '#1677ff',
colorFinish: '#1677ff',
colorError: '#ff4d4f',
to: 'body'
});
const showLoadingBar = ref(false);
const loadingBarRef = ref(); // 加载条 DOM 引用
const loadingStarted = ref(false); // 加载条是否开始
const loadingFinishing = ref(false); // 加载条是否完成
const loadingErroring = ref(false); // 加载条是否报错
async function init() {
showLoadingBar.value = false;
loadingFinishing.value = false;
loadingErroring.value = false;
}
async function start(from = 0, to = 80, status: 'starting' | 'error' = 'starting') {
// 加载条开始加载的回调函数
loadingStarted.value = true;
await init();
if (loadingFinishing.value) {
return;
}
showLoadingBar.value = true;
await nextTick();
if (!loadingBarRef.value) {
return;
}
loadingBarRef.value.style.transition = 'none'; // 禁用过渡
loadingBarRef.value.style.maxWidth = `${from}%`;
void loadingBarRef.value.offsetWidth; // 触发浏览器回流(重排)
loadingBarRef.value.className = `loading-bar loading-bar-${status}`;
loadingBarRef.value.style.transition = '';
loadingBarRef.value.style.maxWidth = `${to}%`;
}
async function finish() {
// 加载条结束加载的回调函数
if (loadingFinishing.value || loadingErroring.value) {
return;
}
if (loadingStarted.value) {
await nextTick();
}
loadingFinishing.value = true;
if (!loadingBarRef.value) {
return;
}
loadingBarRef.value.className = 'loading-bar loading-bar-finishing';
loadingBarRef.value.style.maxWidth = '100%';
void loadingBarRef.value.offsetWidth; // 触发浏览器回流(重排)
showLoadingBar.value = false;
}
function error() {
// 加载条出现错误的回调函数
if (loadingFinishing.value || loadingErroring.value) {
return;
}
if (!showLoadingBar.value) {
void start(100, 100, 'error').then(() => {
loadingErroring.value = true;
});
} else {
loadingErroring.value = true;
if (!loadingBarRef.value) {
return;
}
loadingBarRef.value.className = 'loading-bar loading-bar-error';
loadingBarRef.value.style.maxWidth = '100%';
void loadingBarRef.value.offsetWidth;
showLoadingBar.value = false;
}
}
function onAfterEnter() {
if (loadingErroring.value) {
showLoadingBar.value = false;
}
}
async function onAfterLeave() {
await init();
}
defineExpose({
start,
finish,
error
});
</script>
<template>
<Teleport :disabled="!to" :to="to">
<Transition name="fade-in" @after-enter="onAfterEnter" @after-leave="onAfterLeave">
<div v-show="showLoadingBar" class="m-loading-bar-container" :class="containerClass" :style="containerStyle">
<div
ref="loadingBarRef"
class="loading-bar"
:style="`--loading-bar-size: ${loadingBarSize}px; --color-loading: ${colorLoading}; --color-finish: ${colorFinish}; --color-error: ${colorError}; max-width: 100%;`"
></div>
</div>
</Transition>
</Teleport>
</template>
<style lang="less" scoped>
.fade-in-enter-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.fade-in-leave-active {
transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
}
.fade-in-enter-from,
.fade-in-leave-to {
opacity: 0;
}
.m-loading-bar-container {
z-index: 9999;
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--loading-bar-size);
.loading-bar {
width: 100%;
transition: max-width 4s linear,
background 0.2s linear;
height: var(--loading-bar-size);
border-radius: var(--loading-bar-size);
}
.loading-bar-starting {
background: var(--color-loading);
}
.loading-bar-finishing {
background: var(--color-finish);
transition: max-width 0.2s linear,
background 0.2s linear;
}
.loading-bar-error {
background: var(--color-error);
transition: max-width 0.2s linear,
background 0.2s linear;
}
}
</style>

View File

@@ -1,429 +0,0 @@
<script setup lang="ts">
import type {CSSProperties, VNode} from 'vue';
import {computed, ref, watch} from 'vue';
import {cancelRaf, rafTimeout} from '../Utils';
interface Props {
content?: string // 提示内容
duration?: number // 自动关闭的延时,单位 ms设置 null 时,不自动关闭
top?: string | number // 消息距离顶部的位置,单位 px
}
const props = withDefaults(defineProps<Props>(), {
content: undefined,
duration: 3000,
top: 30
});
interface Message {
content?: string // 提示内容
icon?: VNode // 自定义图标
duration?: number | null // 自动关闭的延时时长,单位 ms设置 null 时,不自动关闭
top?: string | number // 消息距离顶部的位置,单位 px
class?: string // 自定义类名
style?: CSSProperties // 自定义样式
onClick?: () => void // 点击 message 时的回调函数
onClose?: () => void // 关闭时的回调函数
mode?: 'open' | 'info' | 'success' | 'error' | 'warning' | 'loading' // 类型
}
const resetTimer = ref();
const showMessage = ref<boolean[]>([]);
const hideTimers = ref<any[]>([]);
const messageContent = ref<Message[]>([]);
const closeDuration = ref<number | null>(null); // 自动关闭延时
const emits = defineEmits(['click', 'close']);
const messageTop = ref<string>();
const clear = computed(() => {
// 所有提示是否已经全部变为false
return showMessage.value.every((show) => !show);
});
watch(clear, (to, from) => {
// 所有提示都消失后重置
if (!from && to) {
resetTimer.value = rafTimeout(() => {
messageContent.value.splice(0);
showMessage.value.splice(0);
}, 300);
}
});
function onEnter(index: number) {
if (hideTimers.value[index]) {
cancelRaf(hideTimers.value[index]);
}
}
function onLeave(index: number) {
hideMessage(index);
}
function onClick(e: Event, index: number) {
if (messageContent.value[index].onClick) {
messageContent.value[index].onClick();
}
emits('click', e);
}
function hideMessage(index: number) {
if (closeDuration.value !== null) {
hideTimers.value[index] = rafTimeout(() => {
showMessage.value[index] = false;
if (messageContent.value[index].onClose) {
messageContent.value[index].onClose();
}
emits('close');
}, closeDuration.value);
}
}
function show() {
if (resetTimer.value) {
cancelRaf(resetTimer.value);
}
const index = messageContent.value.length - 1;
const last = messageContent.value[index];
if (last.top !== undefined) {
messageTop.value = typeof last.top === 'number' ? `${last.top}px` : last.top;
} else {
messageTop.value = typeof props.top === 'number' ? `${props.top}px` : props.top;
}
showMessage.value[index] = true;
if (last.duration !== null) {
closeDuration.value = last.duration || props.duration;
hideMessage(index);
} else {
closeDuration.value = null;
}
}
function open(message: string | Message) {
if (typeof message === 'string') {
messageContent.value.push({
content: message,
mode: 'open'
});
} else {
messageContent.value.push({
...message,
mode: 'open'
});
}
show();
}
function info(message: string | Message) {
if (typeof message === 'string') {
messageContent.value.push({
content: message,
mode: 'info'
});
} else {
messageContent.value.push({
...message,
mode: 'info'
});
}
show();
}
function success(message: string | Message) {
if (typeof message === 'string') {
messageContent.value.push({
content: message,
mode: 'success'
});
} else {
messageContent.value.push({
...message,
mode: 'success'
});
}
show();
}
function error(message: string | Message) {
if (typeof message === 'string') {
messageContent.value.push({
content: message,
mode: 'error'
});
} else {
messageContent.value.push({
...message,
mode: 'error'
});
}
show();
}
function warning(message: string | Message) {
if (typeof message === 'string') {
messageContent.value.push({
content: message,
mode: 'warning'
});
} else {
messageContent.value.push({
...message,
mode: 'warning'
});
}
show();
}
function loading(message: string | Message) {
if (typeof message === 'string') {
messageContent.value.push({
content: message,
mode: 'loading'
});
} else {
messageContent.value.push({
...message,
mode: 'loading'
});
}
show();
}
defineExpose({
open,
info,
success,
error,
warning,
loading
});
</script>
<template>
<div class="m-message-wrap" :style="`top: ${messageTop};`">
<TransitionGroup name="slide-fade">
<div
v-show="showMessage[index]"
class="m-message"
:class="message.class"
:style="message.style"
v-for="(message, index) in messageContent"
:key="index"
>
<div
class="m-message-content"
:class="`icon-${message.mode}`"
@mouseenter="onEnter(index)"
@mouseleave="onLeave(index)"
@click="onClick($event, index)"
>
<component v-if="message.icon" :is="message.icon" class="icon-svg"/>
<svg
v-else-if="message.mode === 'info'"
class="icon-svg"
focusable="false"
data-icon="info-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm32 664c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V456c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272zm-32-344a48.01 48.01 0 010-96 48.01 48.01 0 010 96z"
></path>
</svg>
<svg
v-else-if="message.mode === 'success'"
class="icon-svg"
focusable="false"
data-icon="check-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm193.5 301.7l-210.6 292a31.8 31.8 0 01-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z"
></path>
</svg>
<svg
v-else-if="message.mode === 'error'"
class="icon-svg"
focusable="false"
data-icon="close-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
fill-rule="evenodd"
viewBox="64 64 896 896"
>
<path
d="M512 64c247.4 0 448 200.6 448 448S759.4 960 512 960 64 759.4 64 512 264.6 64 512 64zm127.98 274.82h-.04l-.08.06L512 466.75 384.14 338.88c-.04-.05-.06-.06-.08-.06a.12.12 0 00-.07 0c-.03 0-.05.01-.09.05l-45.02 45.02a.2.2 0 00-.05.09.12.12 0 000 .07v.02a.27.27 0 00.06.06L466.75 512 338.88 639.86c-.05.04-.06.06-.06.08a.12.12 0 000 .07c0 .03.01.05.05.09l45.02 45.02a.2.2 0 00.09.05.12.12 0 00.07 0c.02 0 .04-.01.08-.05L512 557.25l127.86 127.87c.04.04.06.05.08.05a.12.12 0 00.07 0c.03 0 .05-.01.09-.05l45.02-45.02a.2.2 0 00.05-.09.12.12 0 000-.07v-.02a.27.27 0 00-.05-.06L557.25 512l127.87-127.86c.04-.04.05-.06.05-.08a.12.12 0 000-.07c0-.03-.01-.05-.05-.09l-45.02-45.02a.2.2 0 00-.09-.05.12.12 0 00-.07 0z"
></path>
</svg>
<svg
v-else-if="message.mode === 'warning'"
class="icon-svg"
focusable="false"
data-icon="exclamation-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm-32 232c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V296zm32 440a48.01 48.01 0 010-96 48.01 48.01 0 010 96z"
></path>
</svg>
<svg
v-else-if="message.mode === 'loading'"
width="1em"
height="1em"
fill="currentColor"
class="icon-svg circle"
viewBox="0 0 50 50"
>
<circle class="path" cx="25" cy="25" r="20" fill="none"></circle>
</svg>
<div class="message-content">
{{ message.content || content }}
</div>
</div>
</div>
</TransitionGroup>
</div>
</template>
<style lang="less" scoped>
// 滑动渐变过渡效果
.slide-fade-move,
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateY(-16px);
-ms-transform: translateY(-16px); /* IE 9 */
-webkit-transform: translateY(-16px); /* Safari and Chrome */
opacity: 0;
}
.slide-fade-leave-active {
position: absolute;
left: 0;
right: 0;
margin: 0 auto;
}
.m-message-wrap {
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
position: fixed;
z-index: 999; // 突出显示该层级
width: 100%;
left: 0;
right: 0;
pointer-events: none; // 保证整个message区域不遮挡背后元素响应鼠标事件
.m-message {
text-align: center;
&:not(:last-child) {
margin-bottom: 8px;
}
.m-message-content {
display: inline-flex;
gap: 8px;
align-items: center;
padding: 9px 12px;
background: #fff;
border-radius: 8px;
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
pointer-events: auto; // 保证内容区域部分可以正常响应鼠标事件
:deep(.icon-svg) {
display: inline-block;
font-size: 16px;
fill: currentColor;
}
.circle {
display: inline-block;
stroke: currentColor;
animation: loading-rotate 2s linear infinite;
@keyframes loading-rotate {
100% {
transform: rotate(360deg);
}
}
.path {
stroke-dasharray: 90, 150;
stroke-dashoffset: 0;
stroke-width: 5;
stroke-linecap: round;
animation: loading-dash 1.5s ease-in-out infinite;
@keyframes loading-dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -40px;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -120px;
}
}
}
}
.message-content {
display: inline-block;
}
}
.icon-open {
:deep(svg) {
fill: currentColor;
}
}
.icon-info,
.icon-loading {
:deep(svg) {
color: #909399;
fill: currentColor;
}
}
.icon-success {
:deep(svg) {
color: #52c41a;
fill: currentColor;
}
}
.icon-warning {
:deep(svg) {
color: #faad14;
fill: currentColor;
}
}
.icon-error {
:deep(svg) {
color: #ff4d4f;
fill: currentColor;
}
}
}
}
</style>

View File

@@ -1,648 +0,0 @@
<script setup lang="ts">
import type {CSSProperties, Slot, VNode} from 'vue';
import {computed, nextTick, onMounted, onUnmounted, ref, watch, watchEffect} from 'vue';
import Button from '../Button/Button.vue';
interface Props {
width?: string | number // 模态框宽度,单位 px
icon?: VNode | Slot // 自定义图标
title?: string // 模态框标题 string | slot
titleStyle?: CSSProperties // 自定义标题样式
content?: string // 模态框内容 string | slot
contentStyle?: CSSProperties // 自定义内容样式
bodyClass?: string // 自定义 body 类名
bodyStyle?: CSSProperties // 自定义 body 样式
cancelText?: string // 取消按钮文字
cancelProps?: object // 取消按钮 props 配置,参考 Button 组件 Props
okText?: string // 确认按钮文字
okType?: 'default' | 'reverse' | 'primary' | 'danger' | 'dashed' | 'text' | 'link' // 确认按钮类型
okProps?: object // 确认按钮 props 配置,优先级高于 okType参考 Button 组件 Props
noticeText?: string // 通知按钮文字
noticeProps?: object // 通知按钮 props 配置,参考 Button 组件 Props
centered?: boolean // 是否水平垂直居中,否则固定高度水平居中
top?: string | number // 固定高度水平居中时,距顶部高度,仅当 center: false 时生效,单位 px
transformOrigin?: 'mouse' | 'center' // 模态框动画出现的位置
confirmLoading?: boolean // 确认按钮 loading
blockScroll?: boolean // 是否在打开模态框时禁用背景滚动
keyboard?: boolean // 是否支持键盘 esc 关闭
maskClosable?: boolean // 点击蒙层是否允许关闭
maskStyle?: CSSProperties // 自定义蒙层样式
}
const props = withDefaults(defineProps<Props>(), {
width: 420,
icon: undefined,
title: undefined,
titleStyle: () => ({}),
content: undefined,
contentStyle: () => ({}),
bodyClass: undefined,
bodyStyle: () => ({}),
cancelText: '取消',
cancelProps: () => ({}),
okText: '确定',
okType: 'primary',
okProps: () => ({}),
noticeText: '知道了',
noticeProps: () => ({}),
centered: false,
top: 100,
transformOrigin: 'mouse',
confirmLoading: false,
blockScroll: true,
keyboard: true,
maskClosable: true,
maskStyle: () => ({})
});
interface Modal {
width?: string | number // 模态框宽度,单位 px
icon?: VNode // 自定义图标
title?: string // 模态框标题
titleStyle?: CSSProperties // 自定义标题样式
content?: string // 模态框内容
contentStyle?: CSSProperties // 自定义内容样式
bodyClass?: string // 自定义 body 类名
bodyStyle?: CSSProperties // 自定义 body 样式
cancelText?: string // 取消按钮文字
cancelProps?: object // 取消按钮 props 配置,参考 Button 组件 Props
okText?: string // 确认按钮文字
okType?: 'default' | 'reverse' | 'primary' | 'danger' | 'dashed' | 'text' | 'link' // 确认按钮类型
okProps?: object // 确认按钮 props 配置,优先级高于 okType参考 Button 组件 Props
noticeText?: string // 通知按钮文字
noticeProps?: object // 通知按钮 props 配置,参考 Button 组件 Props
centered?: boolean // 是否水平垂直居中,否则固定高度水平居中
top?: string | number // 固定高度水平居中时,距顶部高度,仅当 center: false 时生效,单位 px
transformOrigin?: 'mouse' | 'center' // 模态框动画出现的位置
blockScroll?: boolean // 是否在打开模态框时禁用背景滚动
keyboard?: boolean // 是否支持键盘 esc 关闭
maskClosable?: boolean // 点击蒙层是否允许关闭
maskStyle?: CSSProperties // 自定义蒙层样式
onKnow?: any // 点击知道了按钮的回调
onOk?: any // 点击确认按钮的回调
onCancel?: any // 点击遮罩层或取消按钮的回调
}
const modalWrapRef = ref(); // modal DOM 引用
const mousePosition = ref<{ x: number; y: number } | null>(null); // 鼠标点击位置
const modalOpen = ref<boolean>(false);
const showModalWrap = ref<boolean>(false);
const confirmBtnLoading = ref<boolean>(false);
// eslint-disable-next-line vue/no-dupe-keys
const transformOrigin = ref<string>('50% 50%');
const modalData = ref<Modal>();
const modalMode = ref(); // 弹窗类型:'info' 'success' 'error' 'warning' 'confirm' 'erase'
const emits = defineEmits(['update:open', 'cancel', 'ok', 'know']);
const modalWidth = computed(() => {
const width = getComputedValue('width');
return typeof width === 'number' ? `${width}px` : width;
});
const modalTop = computed(() => {
const top = getComputedValue('top');
return typeof top === 'number' ? `${top}px` : top;
});
const modalCentered = computed(() => {
return getComputedValue('centered');
});
const modalStyle = computed(() => {
if (modalCentered.value) {
return {
width: modalWidth.value,
transformOrigin: transformOrigin.value
} as CSSProperties;
} else {
return {
width: modalWidth.value,
top: modalTop.value,
transformOrigin: transformOrigin.value
} as CSSProperties;
}
});
const modalTitleStyle = computed(() => {
return getComputedValue('titleStyle') as CSSProperties;
});
const modalContentStyle = computed(() => {
return getComputedValue('contentStyle') as CSSProperties;
});
const modalBodyClass = computed(() => {
return getComputedValue('bodyClass');
});
const modalBodyStyle = computed(() => {
return getComputedValue('bodyStyle') as CSSProperties;
});
const modalMaskStyle = computed(() => {
return getComputedValue('maskStyle') as CSSProperties;
});
const modalIcon = computed(() => {
return getComputedValue('icon');
});
const modalTitle = computed(() => {
return getComputedValue('title');
});
const modalContent = computed(() => {
return getComputedValue('content');
});
const modalCancelProps: object = computed(() => {
return getComputedValue('cancelProps');
});
const modalCancelText = computed(() => {
return getComputedValue('cancelText');
});
const modalOkType = computed(() => {
return getComputedValue('okType') as 'default' | 'reverse' | 'primary' | 'danger' | 'dashed' | 'text' | 'link';
});
const modalOkProps: object = computed(() => {
return getComputedValue('okProps');
});
const modalOkText = computed(() => {
return getComputedValue('okText');
});
const modalNoticeProps: object = computed(() => {
return getComputedValue('noticeProps');
});
const modalNoticeText = computed(() => {
return getComputedValue('noticeText');
});
watch(
modalOpen,
async (to) => {
const blockScroll = getComputedValue('blockScroll');
if (to) {
await nextTick();
modalWrapRef.value.focus();
if (blockScroll) {
// 锁定滚动
document.documentElement.style.overflowY = 'hidden';
document.body.style.overflowY = 'hidden';
}
} else {
if (blockScroll) {
// 解锁滚动
document.documentElement.style.removeProperty('overflow-y');
document.body.style.removeProperty('overflow-y');
}
}
},
{
immediate: true
}
);
watchEffect(() => {
confirmBtnLoading.value = props.confirmLoading;
});
onMounted(() => {
document.addEventListener('click', getClickPosition, true); // 事件在捕获阶段执行
});
onUnmounted(() => {
document.removeEventListener('click', getClickPosition, true);
});
function getClickPosition(e: MouseEvent) {
if (!modalOpen.value) {
mousePosition.value = {
x: e.clientX, // 相对于浏览器视口左上角的 X 坐标,不页面滚动而改变
y: e.clientY // 相对于浏览器视口左上角的 Y 坐标,不页面滚动而改变
};
}
}
async function onBeforeEnter(el: Element) {
showModalWrap.value = true;
await nextTick();
const transOrigin = getComputedValue('transformOrigin');
if (transOrigin === 'mouse' && mousePosition.value) {
const rect = el.getBoundingClientRect();
transformOrigin.value = `${mousePosition.value.x - rect.left}px ${mousePosition.value.y - rect.top}px`;
} else {
transformOrigin.value = '50% 50%';
}
}
function onBeforeLeave(el: Element) {
const transOrigin = getComputedValue('transformOrigin');
if (transOrigin === 'mouse' && mousePosition.value) {
const rect = el.getBoundingClientRect();
transformOrigin.value = `${mousePosition.value.x - rect.left}px ${mousePosition.value.y - rect.top}px`;
} else {
transformOrigin.value = '50% 50%';
}
}
function onAfterLeave() {
showModalWrap.value = false;
}
function getComputedValue(key: keyof Props) {
let computedValue = props[key as keyof Props];
if (modalData.value?.[key as keyof Modal] !== undefined) {
computedValue = modalData.value[key as keyof Modal];
}
return computedValue;
}
function info(data: Modal) {
modalMode.value = 'info';
modalData.value = data;
openModal();
}
function success(data: Modal) {
modalMode.value = 'success';
modalData.value = data;
openModal();
}
function error(data: Modal) {
modalMode.value = 'error';
modalData.value = data;
openModal();
}
function warning(data: Modal) {
modalMode.value = 'warning';
modalData.value = data;
openModal();
}
function confirm(data: Modal) {
modalMode.value = 'confirm';
modalData.value = data;
openModal();
}
function erase(data: Modal) {
modalMode.value = 'erase';
modalData.value = data;
openModal();
}
function openModal() {
modalOpen.value = true;
emits('update:open', true);
}
function onCancel() {
if (modalData.value?.onCancel) {
modalData.value.onCancel();
}
modalOpen.value = false;
emits('cancel');
}
async function onOK() {
if (modalData.value?.onOk) {
confirmBtnLoading.value = true;
await modalData.value.onOk();
confirmBtnLoading.value = false;
}
modalOpen.value = false;
emits('ok');
}
function onKnow() {
if (modalData.value?.onKnow) {
modalData.value.onKnow();
}
modalOpen.value = false;
emits('know');
}
defineExpose({
info,
success,
error,
warning,
confirm,
erase
});
</script>
<template>
<div class="m-modal-root">
<Transition name="fade">
<div v-show="modalOpen" class="m-modal-mask" :style="modalMaskStyle"></div>
</Transition>
<div
v-show="showModalWrap"
tabindex="-1"
ref="modalWrapRef"
class="m-modal-wrap"
:class="{ 'flex-centered': modalCentered }"
@click.self="getComputedValue('maskClosable') ? onCancel() : () => false"
@keydown.esc="getComputedValue('keyboard') ? onCancel() : () => false"
>
<Transition
name="zoom"
enter-from-class="zoom-enter"
enter-active-class="zoom-enter"
enter-to-class="zoom-enter zoom-enter-active"
leave-from-class="zoom-leave"
leave-active-class="zoom-leave zoom-leave-active"
leave-to-class="zoom-leave zoom-leave-active"
@before-enter="onBeforeEnter"
@before-leave="onBeforeLeave"
@after-leave="onAfterLeave"
>
<div v-show="modalOpen" class="m-modal" :style="modalStyle">
<div class="m-modal-body-wrap" :class="modalBodyClass" :style="modalBodyStyle">
<div class="m-modal-body">
<div
class="modal-header"
:class="{
[`icon-${modalMode}`]: ['info', 'success', 'error', 'warning', 'confirm', 'erase'].includes(modalMode)
}"
>
<slot name="icon">
<component v-if="modalIcon" :is="modalIcon" class="icon-svg"/>
<svg
v-else-if="modalMode === 'confirm' || modalMode === 'erase'"
class="icon-svg"
focusable="false"
data-icon="exclamation-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z"
></path>
<path
d="M464 688a48 48 0 1096 0 48 48 0 10-96 0zm24-112h48c4.4 0 8-3.6 8-8V296c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8v272c0 4.4 3.6 8 8 8z"
></path>
</svg>
<svg
v-else-if="modalMode === 'info'"
class="icon-svg"
focusable="false"
data-icon="info-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm32 664c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V456c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272zm-32-344a48.01 48.01 0 010-96 48.01 48.01 0 010 96z"
></path>
</svg>
<svg
v-else-if="modalMode === 'success'"
class="icon-svg"
focusable="false"
data-icon="check-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm193.5 301.7l-210.6 292a31.8 31.8 0 01-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z"
></path>
</svg>
<svg
v-else-if="modalMode === 'error'"
class="icon-svg"
focusable="false"
data-icon="close-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
fill-rule="evenodd"
viewBox="64 64 896 896"
>
<path
d="M512 64c247.4 0 448 200.6 448 448S759.4 960 512 960 64 759.4 64 512 264.6 64 512 64zm127.98 274.82h-.04l-.08.06L512 466.75 384.14 338.88c-.04-.05-.06-.06-.08-.06a.12.12 0 00-.07 0c-.03 0-.05.01-.09.05l-45.02 45.02a.2.2 0 00-.05.09.12.12 0 000 .07v.02a.27.27 0 00.06.06L466.75 512 338.88 639.86c-.05.04-.06.06-.06.08a.12.12 0 000 .07c0 .03.01.05.05.09l45.02 45.02a.2.2 0 00.09.05.12.12 0 00.07 0c.02 0 .04-.01.08-.05L512 557.25l127.86 127.87c.04.04.06.05.08.05a.12.12 0 00.07 0c.03 0 .05-.01.09-.05l45.02-45.02a.2.2 0 00.05-.09.12.12 0 000-.07v-.02a.27.27 0 00-.05-.06L557.25 512l127.87-127.86c.04-.04.05-.06.05-.08a.12.12 0 000-.07c0-.03-.01-.05-.05-.09l-45.02-45.02a.2.2 0 00-.09-.05.12.12 0 00-.07 0z"
></path>
</svg>
<svg
v-else-if="modalMode === 'warning'"
class="icon-svg"
focusable="false"
data-icon="exclamation-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm-32 232c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V296zm32 440a48.01 48.01 0 010-96 48.01 48.01 0 010 96z"
></path>
</svg>
</slot>
<div class="modal-title" :style="modalTitleStyle">
<slot name="title">{{ modalTitle }}</slot>
</div>
</div>
<div class="modal-content" :style="modalContentStyle">
<slot>{{ modalContent }}</slot>
</div>
</div>
<div class="modal-btns">
<template v-if="['confirm', 'erase'].includes(modalMode)">
<Button class="mr8" @click="onCancel" v-bind="modalCancelProps">
{{ modalCancelText }}
</Button>
<Button :type="modalOkType" :loading="confirmBtnLoading" @click="onOK" v-bind="modalOkProps">
{{ modalOkText }}
</Button>
</template>
<Button
v-if="['info', 'success', 'error', 'warning'].includes(modalMode)"
type="primary"
:loading="confirmBtnLoading"
@click="onKnow"
v-bind="modalNoticeProps"
>
{{ modalNoticeText }}
</Button>
</div>
</div>
</div>
</Transition>
</div>
</div>
</template>
<style lang="less" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s linear;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.zoom-enter {
transform: none;
opacity: 0;
animation-duration: 0.3s;
animation-fill-mode: both;
animation-timing-function: cubic-bezier(0.08, 0.82, 0.17, 1);
animation-play-state: paused;
}
.zoom-enter-active {
animation-name: zoomIn;
animation-play-state: running;
@keyframes zoomIn {
0% {
transform: scale(0.2);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
}
.zoom-leave {
animation-duration: 0.2s;
animation-fill-mode: both;
animation-play-state: paused;
animation-timing-function: cubic-bezier(0.78, 0.14, 0.15, 0.86);
}
.zoom-leave-active {
animation-name: zoomOut;
animation-play-state: running;
@keyframes zoomOut {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(0.2);
opacity: 0;
}
}
}
.m-modal-mask {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
z-index: 1000;
background: rgba(0, 0, 0, 0.45);
}
.m-modal-wrap {
position: fixed;
inset: 0;
overflow: auto;
outline: 0;
z-index: 1010;
.m-modal {
position: relative;
margin: 0 auto;
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
line-height: 1.5714285714285714;
padding-bottom: 24px;
outline: none;
.m-modal-body-wrap {
position: relative;
word-break: break-all;
padding: 20px 24px;
background-color: #fff;
border-radius: 8px;
width: auto;
max-width: calc(100vw - 32px);
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
.m-modal-body {
display: flex;
flex-wrap: wrap;
align-items: center;
.modal-header {
display: flex;
align-items: center;
:deep(.icon-svg) {
flex-shrink: 0;
align-self: flex-start;
display: inline-block;
margin-right: 12px;
margin-top: 1px;
font-size: 22px;
fill: currentColor;
}
.modal-title {
display: inline-block;
font-size: 16px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5;
font-weight: 600;
}
:deep(svg) {
fill: currentColor;
}
}
.icon-confirm,
.icon-erase {
color: #faad14;
}
.icon-info {
color: #fff;
}
.icon-success {
color: #52c41a;
}
.icon-error {
color: #ff4d4f;
}
.icon-warning {
color: #faad14;
}
.modal-content {
flex-basis: 100%;
margin-top: 8px;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
margin-left: 34px;
max-width: calc(100% - 34px);
}
}
.modal-btns {
margin-top: 12px;
text-align: right;
.mr8 {
margin-right: 8px;
}
}
}
}
}
.flex-centered {
display: flex;
justify-content: center;
align-items: center;
.m-modal {
padding-bottom: 0;
}
}
</style>

View File

@@ -1,454 +0,0 @@
<script setup lang="ts">
import type {CSSProperties, VNode} from 'vue';
import {computed, nextTick, ref, watch, watchEffect} from 'vue';
import {cancelRaf, rafTimeout} from '../Utils';
interface Props {
title?: string // 通知提醒标题,优先级低于 Notification 中的 title
description?: string // 通知提醒内容,优先级低于 Notification 中的 description
duration?: number | null // 自动关闭的延时时长,单位 ms设置 null 时,不自动关闭,优先级低于 Notification 中的 duration
top?: number // 消息从顶部弹出时,距离顶部的位置,单位 px
bottom?: number // 消息从底部弹出时,距离底部的位置,单位 px
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' // 消息弹出位置,优先级低于 Notification 中的 placement
}
const props = withDefaults(defineProps<Props>(), {
title: undefined,
description: undefined,
duration: 4500,
top: 24,
bottom: 24,
placement: 'topRight'
});
interface Notification {
title?: string // 通知提醒标题
description?: string // 通知提醒内容
icon?: VNode // 自定义图标
class?: string // 自定义类名
style?: CSSProperties // 自定义样式
duration?: number | null // 自动关闭的延时时长,单位 ms设置 null 时,不自动关闭
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' // 通知提醒弹出位置
onClose?: any // 关闭时的回调函数
}
const resetTimer = ref();
const hideIndex = ref<number[]>([]);
const hideTimers = ref<any[]>([]);
const notificationData = ref<any[]>([]);
const closeDuration = ref<number | null>(null); // 自动关闭延时
const notificationPlace = ref(); // 弹出位置
const notificationRef = ref(); // notificationData 数组的 DOM 引用
const emit = defineEmits(['close']);
const topStyle = computed(() => {
if (['topRight', 'topLeft'].includes(notificationPlace.value)) {
return {
top: `${props.top}px`
};
}
return {};
});
const bottomStyle = computed(() => {
if (['bottomRight', 'bottomLeft'].includes(notificationPlace.value)) {
return {
bottom: `${props.bottom}px`
};
}
return {};
});
const clear = computed(() => {
// 所有提示是否已经全部变为 false
return hideIndex.value.length === notificationData.value.length;
});
watch(
clear,
(to, from) => {
// 所有提示都消失后重置
if (!from && to) {
resetTimer.value = rafTimeout(() => {
hideIndex.value.splice(0);
notificationData.value.splice(0);
}, 300);
}
},
{
flush: 'post'
}
);
watchEffect(() => {
notificationPlace.value = props.placement;
});
function onEnter(index: number) {
stopAutoClose(index);
}
function onLeave(index: number) {
if (!hideIndex.value.includes(index)) {
autoClose(index);
}
}
function stopAutoClose(index: number) {
cancelRaf(hideTimers.value[index]);
hideTimers.value[index] = null;
}
function autoClose(index: number) {
if (closeDuration.value !== null) {
hideTimers.value[index] = rafTimeout(() => {
onClose(index);
}, closeDuration.value);
}
}
async function onClose(index: number) {
notificationRef.value[index].style.maxHeight = notificationRef.value[index].offsetHeight + 'px';
await nextTick();
hideIndex.value.push(index);
notificationData.value[index].onClose();
emit('close');
}
function show() {
cancelRaf(resetTimer.value);
hideTimers.value.push(null);
const index = notificationData.value.length - 1;
const last = notificationData.value[index];
if (last.placement) {
notificationPlace.value = last.placement;
}
if (last.duration !== null) {
closeDuration.value = last.duration || props.duration;
autoClose(index);
} else {
closeDuration.value = null;
}
}
function open(notification: Notification) {
notificationData.value.push({
...notification,
mode: 'open'
});
show();
}
function info(notification: Notification) {
notificationData.value.push({
...notification,
mode: 'info'
});
show();
}
function success(notification: Notification) {
notificationData.value.push({
...notification,
mode: 'success'
});
show();
}
function error(notification: Notification) {
notificationData.value.push({
...notification,
mode: 'error'
});
show();
}
function warning(notification: Notification) {
notificationData.value.push({
...notification,
mode: 'warning'
});
show();
}
defineExpose({
open,
info,
success,
error,
warning
});
</script>
<template>
<div
class="m-notification-wrap"
:class="`notification-${notificationPlace}`"
:style="{ ...topStyle, ...bottomStyle }"
>
<TransitionGroup :name="['topRight', 'bottomRight'].includes(notificationPlace) ? 'right' : 'left'">
<div
v-show="!hideIndex.includes(index)"
ref="notificationRef"
class="m-notification-content"
:class="[`icon-${notification.mode}`, notification.class]"
:style="notification.style"
v-for="(notification, index) in notificationData"
:key="index"
@mouseenter="onEnter(index)"
@mouseleave="onLeave(index)"
>
<component v-if="notification.icon" :is="notification.icon" class="icon-svg"/>
<svg
v-else-if="notification.mode === 'info'"
class="icon-svg"
viewBox="64 64 896 896"
data-icon="info-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
focusable="false"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z"
></path>
<path
d="M464 336a48 48 0 1 0 96 0 48 48 0 1 0-96 0zm72 112h-48c-4.4 0-8 3.6-8 8v272c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V456c0-4.4-3.6-8-8-8z"
></path>
</svg>
<svg
v-else-if="notification.mode === 'success'"
class="icon-svg"
viewBox="64 64 896 896"
data-icon="check-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
focusable="false"
>
<path
d="M699 353h-46.9c-10.2 0-19.9 4.9-25.9 13.3L469 584.3l-71.2-98.8c-6-8.3-15.6-13.3-25.9-13.3H325c-6.5 0-10.3 7.4-6.5 12.7l124.6 172.8a31.8 31.8 0 0 0 51.7 0l210.6-292c3.9-5.3.1-12.7-6.4-12.7z"
></path>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z"
></path>
</svg>
<svg
v-else-if="notification.mode === 'warning'"
class="icon-svg"
viewBox="64 64 896 896"
data-icon="exclamation-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
focusable="false"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z"
></path>
<path
d="M464 688a48 48 0 1 0 96 0 48 48 0 1 0-96 0zm24-112h48c4.4 0 8-3.6 8-8V296c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8v272c0 4.4 3.6 8 8 8z"
></path>
</svg>
<svg
v-else-if="notification.mode === 'error'"
class="icon-svg"
viewBox="64 64 896 896"
data-icon="close-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
focusable="false"
>
<path
d="M685.4 354.8c0-4.4-3.6-8-8-8l-66 .3L512 465.6l-99.3-118.4-66.1-.3c-4.4 0-8 3.5-8 8 0 1.9.7 3.7 1.9 5.2l130.1 155L340.5 670a8.32 8.32 0 0 0-1.9 5.2c0 4.4 3.6 8 8 8l66.1-.3L512 564.4l99.3 118.4 66 .3c4.4 0 8-3.5 8-8 0-1.9-.7-3.7-1.9-5.2L553.5 515l130.1-155c1.2-1.4 1.8-3.3 1.8-5.2z"
></path>
<path
d="M512 65C264.6 65 64 265.6 64 513s200.6 448 448 448 448-200.6 448-448S759.4 65 512 65zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z"
></path>
</svg>
<div class="notification-content">
<div class="notification-title">{{ notification.title || title }}</div>
<div class="notification-description">{{ notification.description || description }}</div>
</div>
<a tabindex="0" class="notification-close" @click="onClose(index)">
<svg
class="close-svg"
viewBox="64 64 896 896"
data-icon="close"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
focusable="false"
>
<path
d="M563.8 512l262.5-312.9c4.4-5.2.7-13.1-6.1-13.1h-79.8c-4.7 0-9.2 2.1-12.3 5.7L511.6 449.8 295.1 191.7c-3-3.6-7.5-5.7-12.3-5.7H203c-6.8 0-10.5 7.9-6.1 13.1L459.4 512 196.9 824.9A7.95 7.95 0 0 0 203 838h79.8c4.7 0 9.2-2.1 12.3-5.7l216.5-258.1 216.5 258.1c3 3.6 7.5 5.7 12.3 5.7h79.8c6.8 0 10.5-7.9 6.1-13.1L563.8 512z"
></path>
</svg>
</a>
</div>
</TransitionGroup>
</div>
</template>
<style lang="less" scoped>
.right-move, // 对移动中的元素应用的过渡
.right-enter-active,
.right-leave-active,
.left-move,
.left-enter-active,
.left-leave-active {
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
}
.right-leave-to,
.left-leave-to {
max-height: 0 !important;
opacity: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
margin-bottom: 0 !important;
}
.right-enter-from {
transform: translateX(100%);
opacity: 0;
}
/* 确保将离开的元素从布局流中删除
以便能够正确地计算移动的动画。 */
.right-leave-active {
position: absolute;
right: 0;
}
.left-enter-from {
transform: translateX(-100%);
opacity: 0;
}
.left-leave-active {
position: absolute;
left: 0;
}
.m-notification-wrap {
position: fixed;
z-index: 999; // 突出显示该层级
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
line-height: 1.5714285714285714;
margin-right: 24px;
.m-notification-content {
position: relative;
display: flex;
width: 384px;
max-width: calc(100vw - 48px);
margin-bottom: 16px;
margin-left: auto;
padding: 20px 24px;
overflow: hidden;
line-height: 1.5714285714285714;
word-break: break-all;
background: #fff;
border-radius: 8px;
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
:deep(.icon-svg) {
flex-shrink: 0;
display: inline-block;
font-size: 24px;
fill: currentColor;
margin-right: 12px;
svg {
fill: currentColor;
}
}
.notification-content {
width: 100%;
.notification-title {
padding-right: 24px;
margin-bottom: 8px;
font-size: 16px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5;
}
.notification-description {
font-size: 14px;
}
}
.notification-close {
position: absolute;
top: 20px;
right: 24px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(0, 0, 0, 0.45);
outline: none;
width: 22px;
height: 22px;
border-radius: 4px;
transition: background-color 0.2s,
color 0.2s;
.close-svg {
display: inline-block;
font-size: 14px;
fill: currentColor;
transition: color 0.2s;
}
&:hover {
background: rgba(0, 0, 0, 0.06);
.close-svg {
color: rgba(0, 0, 0, 0.88);
}
}
}
}
.icon-info {
:deep(.icon-svg) {
color: @themeColor;
}
}
.icon-success {
:deep(.icon-svg) {
color: #52c41a;
}
}
.icon-warning {
:deep(.icon-svg) {
color: #faad14;
}
}
.icon-error {
:deep(.icon-svg) {
color: #ff4d4f;
}
}
}
.notification-topRight,
.notification-bottomRight {
margin-right: 24px;
right: 0;
}
.notification-topLeft,
.notification-bottomLeft {
margin-left: 24px;
left: 0;
}
</style>

View File

@@ -1,99 +0,0 @@
<script setup lang="ts">
import type {CSSProperties} from 'vue';
import {computed, onMounted, ref, watch, watchEffect} from 'vue';
import {formatNumber} from '../Utils';
import {TransitionPresets, useTransition} from '@vueuse/core';
enum TransitionFunc {
linear = 'linear',
easeOutSine = 'easeOutSine',
easeInOutSine = 'easeInOutSine',
easeInQuad = 'easeInQuad',
easeOutQuad = 'easeOutQuad',
easeInOutQuad = 'easeInOutQuad',
easeInCubic = 'easeInCubic',
easeOutCubic = 'easeOutCubic',
easeInOutCubic = 'easeInOutCubic',
easeInQuart = 'easeInQuart',
easeOutQuart = 'easeOutQuart',
easeInOutQuart = 'easeInOutQuart',
easeInQuint = 'easeInQuint',
easeOutQuint = 'easeOutQuint',
easeInOutQuint = 'easeInOutQuint',
easeInExpo = 'easeInExpo',
easeOutExpo = 'easeOutExpo',
easeInOutExpo = 'easeInOutExpo',
easeInCirc = 'easeInCirc',
easeOutCirc = 'easeOutCirc',
easeInOutCirc = 'easeInOutCirc',
easeInBack = 'easeInBack',
easeOutBack = 'easeOutBack',
easeInOutBack = 'easeInOutBack'
}
interface Props {
from?: number // 数值动画起始数值
to?: number // 数值目标值
duration?: number // 数值动画持续时间,单位 ms
autoplay?: boolean // 是否自动开始动画
precision?: number // 精度,保留小数点后几位
prefix?: string // 前缀
suffix?: string // 后缀
separator?: string // 千分位分隔符
decimal?: string // 小数点字符
valueStyle?: CSSProperties // 数值文本样式
transition?: TransitionFunc // 动画过渡效果
}
const props = withDefaults(defineProps<Props>(), {
from: 0,
to: 1000,
duration: 3000,
autoplay: true,
precision: 0,
prefix: undefined,
suffix: undefined,
separator: ',',
decimal: '.',
valueStyle: () => ({}),
transition: TransitionFunc['easeInOutCubic']
});
const source = ref(props.from);
const emits = defineEmits(['started', 'finished']);
watchEffect(() => {
source.value = props.from;
});
watch([() => props.from, () => props.to], () => {
if (props.autoplay) {
play();
}
});
onMounted(() => {
if (props.autoplay) {
play();
}
});
const outputValue = useTransition(source, {
duration: props.duration,
transition: TransitionPresets[props.transition],
onFinished: () => emits('finished'),
onStarted: () => emits('started')
});
function play() {
source.value = props.to;
}
const showValue = computed(() => {
const {precision, separator, decimal, prefix, suffix} = props;
return formatNumber(outputValue.value, precision, separator, decimal, prefix, suffix);
});
defineExpose({
play
});
</script>
<template>
<span :style="valueStyle">
{{ showValue }}
</span>
</template>

View File

@@ -1,571 +0,0 @@
<script setup lang="ts">
import {computed, nextTick, ref, watch} from 'vue';
import Input from '../Input/Input.vue';
import Select from '../Select/Select.vue';
interface Props {
page?: number // (v-model) 当前页数
pageSize?: number // (v-model) 每页条数
total?: number // 数据总数
disabled?: boolean // 是否禁用
pageAmount?: number // 显示的页码数
hideOnSinglePage?: boolean // 只有一页时是否隐藏分页
showQuickJumper?: boolean // 是否可以快速跳转至某页
showSizeChanger?: boolean // 是否展示 pageSize 切换器,当 total 大于 50 时默认为 true
pageSizeOptions?: string[] | number[] // 设置每页可以显示多少条
showTotal?: boolean | ((total: number, range: number[]) => string) // 用于显示数据总量和当前数据顺序
placement?: 'left' | 'center' | 'right' // 分页展示位置,靠左 left居中 center靠右 right
}
const props = withDefaults(defineProps<Props>(), {
page: 1,
pageSize: 10,
total: 0,
disabled: false,
pageAmount: 5,
hideOnSinglePage: false,
showQuickJumper: false,
showSizeChanger: undefined,
pageSizeOptions: () => [10, 20, 50, 100],
showTotal: false,
placement: 'center'
});
const currentPage = ref(props.page); // 当前 page
const currentPageSize = ref(props.pageSize); // 当前 pageSize
const jumpNumber = ref(); // 跳转的页码
const forwardMore = ref(false); // 左省略号展示
const backwardMore = ref(false); // 右省略号展示
const emits = defineEmits(['update:page', 'update:pageSize', 'change', 'pageSizeChange']);
const totalPage = computed(() => {
// 总页数
return Math.ceil(props.total / currentPageSize.value); // 向上取整
});
const totalText = computed(() => {
if (typeof props.showTotal === 'boolean') {
if (props.showTotal) {
return `${props.total}`;
}
} else {
const first = (currentPage.value - 1) * currentPageSize.value + 1;
const last =
currentPage.value * currentPageSize.value > props.total ? props.total : currentPage.value * currentPageSize.value;
return props.showTotal(props.total, [first, last]);
}
return null;
});
const pageList = computed(() => {
// 获取显示的页码数组
return dealPageList(currentPage.value).filter((n) => n !== 1 && n !== totalPage.value);
});
const showPageSizeChanger = computed(() => {
if (typeof props.showSizeChanger === 'boolean') {
return props.showSizeChanger;
} else {
// undefined
return props.total > 50;
}
});
const selectOptions = computed(() => {
const pageSizeOptipns = [currentPageSize.value, ...props.pageSizeOptions].map((pageSize: number | string) =>
Number(pageSize)
);
return [...new Set(pageSizeOptipns)]
.sort((a: number, b: number) => a - b)
.map((pageSize: number) => {
return {
label: `${pageSize} 条/页`,
value: pageSize
};
});
});
watch(
() => props.page,
(to: number) => {
currentPage.value = to;
}
);
watch(
() => props.pageSize,
(to: number) => {
currentPageSize.value = to;
}
);
function dealPageList(curPage: number): number[] {
var resList: number[] = [];
var offset = Math.floor(props.pageAmount / 2); // 向下取整
var pager = {
start: curPage - offset,
end: curPage + offset
};
if (pager.start < 1) {
pager.end = pager.end + (1 - pager.start);
pager.start = 1;
}
if (pager.end > totalPage.value) {
pager.start = pager.start - (pager.end - totalPage.value);
pager.end = totalPage.value;
}
if (pager.start < 1) {
pager.start = 1;
}
if (pager.start > 1) {
forwardMore.value = true;
} else {
forwardMore.value = false;
}
if (pager.end < totalPage.value) {
backwardMore.value = true;
} else {
backwardMore.value = false;
}
// 生成要显示的页码数组
for (let i: number = pager.start; i <= pager.end; i++) {
resList.push(i);
}
return resList;
}
function onPageForward(): void {
currentPage.value = currentPage.value - props.pageAmount > 0 ? currentPage.value - props.pageAmount : 1;
emits('update:page', currentPage.value);
emits('change', currentPage.value, currentPageSize.value);
}
function onPageBackward(): void {
currentPage.value =
currentPage.value + props.pageAmount < totalPage.value ? currentPage.value + props.pageAmount : totalPage.value;
emits('update:page', currentPage.value);
emits('change', currentPage.value, currentPageSize.value);
}
async function onPageJump() {
let num = Number(jumpNumber.value); // 转换为数字
if (jumpNumber.value && Number.isInteger(num)) {
// 是否为整数
if (num < 1) {
num = 1;
}
if (num > totalPage.value) {
num = totalPage.value;
}
onPageChange(num);
}
await nextTick();
jumpNumber.value = undefined; // 清空跳转输入框
}
function onPageChange(page: number): boolean | void {
if (page === 0 || page === totalPage.value + 1) {
return false;
}
if (currentPage.value !== page) {
// 点击的页码不是当前页码
currentPage.value = page;
emits('update:page', currentPage.value);
emits('change', currentPage.value, currentPageSize.value);
}
}
function onPageSizeChange(pageSize: number) {
currentPageSize.value = pageSize;
const maxPage = Math.ceil(props.total / pageSize);
if (currentPage.value > maxPage) {
currentPage.value = maxPage;
}
emits('update:page', currentPage.value);
emits('update:pageSize', currentPageSize.value);
emits('pageSizeChange', currentPage.value, currentPageSize.value);
emits('change', currentPage.value, currentPageSize.value);
}
</script>
<template>
<div
class="m-pagination"
:class="[
`pagination-${placement}`,
{
'pagination-disabled': disabled,
'pagination-hidden': !total || (hideOnSinglePage && total <= currentPageSize)
}
]"
>
<span class="pagination-total-text" v-if="totalText">{{ totalText }}</span>
<span
tabindex="0"
class="pagination-prev"
:class="{ 'item-disabled': currentPage === 1 }"
@keydown.enter.prevent="disabled ? () => false : onPageChange(currentPage - 1)"
@click="disabled || currentPage === 1 ? () => false : onPageChange(currentPage - 1)"
>
<svg
class="arrow-svg"
viewBox="64 64 896 896"
data-icon="left"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
focusable="false"
>
<path
d="M724 218.3V141c0-6.7-7.7-10.4-12.9-6.3L260.3 486.8a31.86 31.86 0 0 0 0 50.3l450.8 352.1c5.3 4.1 12.9.4 12.9-6.3v-77.3c0-4.9-2.3-9.6-6.1-12.6l-360-281 360-281.1c3.8-3 6.1-7.7 6.1-12.6z"
></path>
</svg>
</span>
<span
tabindex="0"
:class="['pagination-item', { 'item-active': currentPage === 1 }]"
@click="disabled ? () => false : onPageChange(1)"
>
1
</span>
<span
v-show="forwardMore && pageList[0] - 1 > 1"
tabindex="0"
ref="forward"
class="pagintion-item-link"
@click="disabled ? () => false : onPageForward()"
>
<span class="u-ellipsis"></span>
<svg class="u-icon" viewBox="64 64 896 896" data-icon="double-left" aria-hidden="true" focusable="false">
<path
d="M272.9 512l265.4-339.1c4.1-5.2.4-12.9-6.3-12.9h-77.3c-4.9 0-9.6 2.3-12.6 6.1L186.8 492.3a31.99 31.99 0 0 0 0 39.5l255.3 326.1c3 3.9 7.7 6.1 12.6 6.1H532c6.7 0 10.4-7.7 6.3-12.9L272.9 512zm304 0l265.4-339.1c4.1-5.2.4-12.9-6.3-12.9h-77.3c-4.9 0-9.6 2.3-12.6 6.1L490.8 492.3a31.99 31.99 0 0 0 0 39.5l255.3 326.1c3 3.9 7.7 6.1 12.6 6.1H836c6.7 0 10.4-7.7 6.3-12.9L576.9 512z"
></path>
</svg>
</span>
<span
tabindex="0"
:class="['pagination-item', { 'item-active': currentPage === page }]"
v-for="(page, index) in pageList"
:key="index"
@click="disabled ? () => false : onPageChange(page)"
>
{{ page }}
</span>
<span
v-show="backwardMore && pageList[pageList.length - 1] + 1 < totalPage"
tabindex="0"
ref="backward"
class="pagintion-item-link"
@click="disabled ? () => false : onPageBackward()"
>
<span class="u-ellipsis"></span>
<svg class="u-icon" viewBox="64 64 896 896" data-icon="double-right" aria-hidden="true" focusable="false">
<path
d="M533.2 492.3L277.9 166.1c-3-3.9-7.7-6.1-12.6-6.1H188c-6.7 0-10.4 7.7-6.3 12.9L447.1 512 181.7 851.1A7.98 7.98 0 0 0 188 864h77.3c4.9 0 9.6-2.3 12.6-6.1l255.3-326.1c9.1-11.7 9.1-27.9 0-39.5zm304 0L581.9 166.1c-3-3.9-7.7-6.1-12.6-6.1H492c-6.7 0-10.4 7.7-6.3 12.9L751.1 512 485.7 851.1A7.98 7.98 0 0 0 492 864h77.3c4.9 0 9.6-2.3 12.6-6.1l255.3-326.1c9.1-11.7 9.1-27.9 0-39.5z"
></path>
</svg>
</span>
<span
v-show="totalPage !== 1"
tabindex="0"
:class="['pagination-item', { 'item-active': currentPage === totalPage }]"
@click="disabled ? () => false : onPageChange(totalPage)"
>
{{ totalPage }}
</span>
<span
tabindex="0"
class="pagination-next"
:class="{ 'item-disabled': currentPage === totalPage }"
@keydown.enter.prevent="disabled ? () => false : onPageChange(currentPage + 1)"
@click="disabled || currentPage === totalPage ? () => false : onPageChange(currentPage + 1)"
>
<svg
class="arrow-svg"
viewBox="64 64 896 896"
data-icon="right"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
focusable="false"
>
<path
d="M765.7 486.8L314.9 134.7A7.97 7.97 0 0 0 302 141v77.3c0 4.9 2.3 9.6 6.1 12.6l360 281.1-360 281.1c-3.9 3-6.1 7.7-6.1 12.6V883c0 6.7 7.7 10.4 12.9 6.3l450.8-352.1a31.96 31.96 0 0 0 0-50.4z"
></path>
</svg>
</span>
<span class="m-pagination-options" v-if="showPageSizeChanger || showQuickJumper">
<Select
v-if="showPageSizeChanger"
:class="{ mr8: showQuickJumper }"
:disabled="disabled"
:options="selectOptions"
@change="onPageSizeChange"
v-model="currentPageSize"
/>
<span class="pagination-jump-page" v-if="showQuickJumper">
跳至<Input
:width="50"
:disabled="disabled"
v-model:value.lazy="jumpNumber"
@change="onPageJump"
@enter="onPageJump"
/>
</span>
</span>
</div>
</template>
<style lang="less" scoped>
.m-pagination {
display: flex;
align-items: center;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
.pagination-total-text {
display: inline-block;
height: 32px;
margin-right: 8px;
line-height: 32px;
}
.pagination-item {
display: inline-block;
text-align: center;
min-width: 32px;
height: 32px;
line-height: 30px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fff;
margin-right: 8px;
cursor: pointer;
outline: none;
user-select: none; // 禁止选取文本
transition: all 0.2s;
&:hover {
.item-active();
}
}
.pagination-prev,
.pagination-next {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
line-height: 30px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fff;
cursor: pointer;
outline: none;
user-select: none; // 禁止选取文本
transition: all 0.2s;
.arrow-svg {
display: inline-block;
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
fill: currentColor;
transition: color 0.2s;
}
&:hover {
border-color: #00aced;
.arrow-svg {
color: #00aced;
}
}
}
.pagination-prev {
margin-right: 8px;
}
.item-active {
// 悬浮/选中样式
font-weight: 600;
color: #00aced;
border-color: #00aced;
}
.item-disabled {
color: rgba(0, 0, 0, 0.25);
background: #fff;
border-color: #d9d9d9;
cursor: not-allowed;
&:hover {
font-weight: 400;
color: rgba(0, 0, 0, 0.65);
border-color: #d9d9d9;
.arrow-svg {
color: rgba(0, 0, 0, 0.25);
}
}
.arrow-svg {
color: rgba(0, 0, 0, 0.25);
}
}
.pagintion-item-link {
position: relative;
display: inline-block;
margin-right: 8px;
min-width: 32px;
height: 32px;
line-height: 32px;
cursor: pointer;
outline: none;
.u-ellipsis {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
margin: auto;
color: rgba(0, 0, 0, 0.25);
font-family: Arial, Helvetica, sans-serif;
line-height: 32px;
letter-spacing: 2px;
text-align: center;
text-indent: 0.13em;
opacity: 1;
transition: all 0.2s;
}
.u-icon {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
margin: auto;
display: inline-block;
fill: #000;
width: 12px;
height: 12px;
opacity: 0;
pointer-events: none;
transition: all 0.2s;
}
&:hover {
.u-ellipsis {
opacity: 0;
pointer-events: none;
}
.u-icon {
opacity: 1;
pointer-events: auto;
}
}
}
.m-pagination-options {
display: inline-block;
margin-left: 16px;
.mr8 {
margin-right: 8px;
}
.pagination-jump-page {
display: inline-block;
height: 32px;
line-height: 32px;
.m-input-wrap {
margin: 0 8px;
height: 32px;
line-height: 30px;
}
}
}
}
.pagination-left {
justify-content: flex-start;
}
.pagination-center {
justify-content: center;
}
.pagination-right {
justify-content: flex-end;
}
.pagination-disabled {
.pagination-prev,
.pagination-next {
color: rgba(0, 0, 0, 0.25);
border-color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
.arrow-svg {
color: rgba(0, 0, 0, 0.25);
}
&:hover {
border-color: rgba(0, 0, 0, 0.25);
.arrow-svg {
color: rgba(0, 0, 0, 0.25);
}
}
}
.pagination-item {
color: rgba(0, 0, 0, 0.25);
border-color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
&:hover {
font-weight: normal;
color: rgba(0, 0, 0, 0.25);
border-color: rgba(0, 0, 0, 0.25);
}
}
.item-active {
border-color: #d9d9d9;
background-color: rgba(0, 0, 0, 0.15);
&:hover {
font-weight: 600;
color: rgba(0, 0, 0, 0.25);
border-color: #d9d9d9;
background-color: rgba(0, 0, 0, 0.15);
}
}
.pagintion-item-link {
color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
&:hover {
.u-ellipsis {
opacity: 1;
pointer-events: none;
}
.u-icon {
opacity: 0;
pointer-events: none;
}
}
}
.m-pagination-options {
color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
}
}
.pagination-hidden {
display: none;
}
</style>

View File

@@ -1,226 +0,0 @@
<script setup lang="ts">
import type {CSSProperties, Slot, VNode} from 'vue';
import {computed, ref} from 'vue';
import {useSlotsExist} from '../Utils';
interface Props {
title?: string // 弹出确认框的标题 string | slot
titleStyle?: CSSProperties // 设置标题的样式
description?: string // 弹出确认框的内容描述 string | slot
descriptionStyle?: CSSProperties // 设置内容描述的样式
tooltipStyle?: CSSProperties // 设置弹出提示的样式
icon?: 'success' | 'info' | 'warning' | 'danger' | VNode | Slot // 自定义 Icon 图标,预置四种类型图标 string | VNode | slot
iconStyle?: CSSProperties // 设置 Icon 图标的样式,一般不需要设置,主要用于自定义 Icon 图标时
cancelText?: string // 取消按钮文字 string | slot
cancelType?: 'default' | 'reverse' | 'primary' | 'danger' | 'dashed' | 'text' | 'link' // 取消按钮类型
cancelProps?: object // 取消按钮 props优先级高于 cancelType参考 Button 组件 props
okText?: string // 确认按钮文字 string | slot
okType?: 'default' | 'reverse' | 'primary' | 'danger' | 'dashed' | 'text' | 'link' // 确认按钮类型
okProps?: object // 确认按钮 props优先级高于 okType参考 Button 组件 props
showCancel?: boolean // 是否显示取消按钮
}
const props = withDefaults(defineProps<Props>(), {
title: undefined,
titleStyle: () => ({}),
description: undefined,
descriptionStyle: () => ({}),
tooltipStyle: () => ({}),
icon: 'warning',
iconStyle: () => ({}),
cancelText: '取消',
cancelType: 'default',
cancelProps: () => ({}),
okText: '确定',
okType: 'primary',
okProps: () => ({}),
showCancel: true
});
const tooltipRef = ref();
const emits = defineEmits(['cancel', 'ok']);
const slotsExist = useSlotsExist(['description']);
const showDesc = computed(() => {
return slotsExist.description || props.description;
});
function onCancel(e: Event) {
emits('cancel', e);
tooltipRef.value.hide();
}
function onOk(e: Event) {
emits('ok', e);
tooltipRef.value.hide();
}
</script>
<template>
<Tooltip
ref="tooltipRef"
max-width="auto"
bg-color="#fff"
:tooltip-style="{
padding: '12px',
borderRadius: '8px',
textAlign: 'start',
...tooltipStyle
}"
trigger="click"
:transition-duration="200"
v-bind="$attrs"
>
<template #tooltip>
<div class="m-popconfirm-message">
<span class="m-popconfirm-icon" :style="iconStyle">
<slot name="icon">
<svg
v-if="icon === 'info'"
class="icon-info"
focusable="false"
width="1em"
height="1em"
fill="currentColor"
viewBox="64 64 896 896"
data-icon="info-circle"
aria-hidden="true"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm32 664c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V456c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272zm-32-344a48.01 48.01 0 0 1 0-96 48.01 48.01 0 0 1 0 96z"
></path>
</svg>
<svg
v-else-if="icon === 'success'"
class="icon-success"
focusable="false"
width="1em"
height="1em"
fill="currentColor"
viewBox="64 64 896 896"
data-icon="check-circle"
aria-hidden="true"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm193.5 301.7l-210.6 292a31.8 31.8 0 0 1-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z"
></path>
</svg>
<svg
v-else-if="icon === 'danger'"
class="icon-danger"
focusable="false"
width="1em"
height="1em"
fill="currentColor"
viewBox="64 64 896 896"
data-icon="close-circle"
aria-hidden="true"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm165.4 618.2l-66-.3L512 563.4l-99.3 118.4-66.1.3c-4.4 0-8-3.5-8-8 0-1.9.7-3.7 1.9-5.2l130.1-155L340.5 359a8.32 8.32 0 0 1-1.9-5.2c0-4.4 3.6-8 8-8l66.1.3L512 464.6l99.3-118.4 66-.3c4.4 0 8 3.5 8 8 0 1.9-.7 3.7-1.9 5.2L553.5 514l130 155c1.2 1.5 1.9 3.3 1.9 5.2 0 4.4-3.6 8-8 8z"
></path>
</svg>
<svg
v-else-if="icon === 'warning'"
class="icon-warning"
focusable="false"
width="1em"
height="1em"
fill="currentColor"
viewBox="64 64 896 896"
data-icon="exclamation-circle"
aria-hidden="true"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm-32 232c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V296zm32 440a48.01 48.01 0 0 1 0-96 48.01 48.01 0 0 1 0 96z"
></path>
</svg>
<component v-else-if="icon" :is="icon"/>
</slot>
</span>
<div class="popconfirm-title" :class="{ 'title-font-weight': showDesc }" :style="titleStyle">
<slot name="title">{{ title }}</slot>
</div>
</div>
<div v-if="showDesc" class="popconfirm-description" :style="descriptionStyle">
<slot name="description">{{ description }}</slot>
</div>
<div class="popconfirm-buttons">
<Button v-if="showCancel" size="small" :type="cancelType" @click="onCancel" v-bind="cancelProps">
<slot name="cancelText">{{ cancelText }}</slot>
</Button>
<Button size="small" :type="okType" @click="onOk" v-bind="okProps">
<slot name="okText">{{ okText }}</slot>
</Button>
</div>
</template>
<slot></slot>
</Tooltip>
</template>
<style lang="less" scoped>
.m-popconfirm-message {
position: relative;
margin-bottom: 8px;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
display: flex;
flex-wrap: nowrap;
align-items: start;
.m-popconfirm-icon {
flex: none;
font-size: 14px;
line-height: 1;
padding-top: 4px;
display: inline-block;
text-align: center;
.icon-svg {
display: inline-block;
fill: currentColor;
}
:deep(svg) {
fill: currentColor;
}
}
.icon-info {
color: #909399;
}
.icon-success {
color: #52c41a;
}
.icon-danger {
color: #ff4d4f;
}
.icon-warning {
color: #faad14;
}
.popconfirm-title {
flex: auto;
margin-left: 8px;
}
.title-font-weight {
font-weight: 600;
}
}
.popconfirm-description {
position: relative;
margin-left: 22px;
margin-bottom: 8px;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
}
.popconfirm-buttons {
text-align: end;
.m-btn {
margin-left: 8px;
}
}
</style>

View File

@@ -1,92 +0,0 @@
<script setup lang="ts">
import type {CSSProperties} from 'vue';
import {computed} from 'vue';
import {useSlotsExist} from '../Utils';
interface Props {
title?: string // 卡片标题 string | slot
titleStyle?: CSSProperties // 卡片标题样式
content?: string // 卡片内容 string | slot
contentStyle?: CSSProperties // 卡片内容样式
tooltipStyle?: CSSProperties // 设置弹出提示的样式
offsetX?: number // 水平偏移量
padding?: string | number // 弹出提示的内边距
}
const props = withDefaults(defineProps<Props>(), {
title: undefined,
titleStyle: () => ({}),
content: undefined,
contentStyle: () => ({}),
tooltipStyle: () => ({}),
offsetX: 0, // 默认偏移量为0
padding: '0px' // 默认内边距为0
});
const slotsExist = useSlotsExist(['title', 'content']);
const showTitle = computed(() => {
return slotsExist.title || props.title;
});
const showContent = computed(() => {
return slotsExist.content || props.content;
});
</script>
<template>
<Tooltip
max-width="auto"
bg-color="#fff"
:tooltip-style="{
padding: props.padding, // 使用传入的 padding
borderRadius: '8px',
textAlign: 'start',
transform: `translate(${props.offsetX}px, 0)`,
backgroundColor: 'var(--background-color)',
color: 'var(--text-color)',
border: '1px solid var(--white-color)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
...tooltipStyle
}"
:transition-duration="200"
v-bind="$attrs"
>
<template #tooltip>
<div class="arrow" :style="{ transform: `translateX(${props.offsetX - 10}px)` }"></div>
<div v-if="showTitle" class="popover-title" :class="{ mb8: showContent }" :style="titleStyle">
<slot name="title">{{ title }}</slot>
</div>
<div v-if="showContent" class="popover-content" :style="contentStyle">
<slot name="content">{{ content }}</slot>
</div>
</template>
<slot></slot>
</Tooltip>
</template>
<style lang="less" scoped>
.popover-title {
min-width: 176px;
color: rgba(0, 0, 0, 0.88);
font-weight: 600;
}
.mb8 {
margin-bottom: 8px;
}
.popover-content {
color: rgba(0, 0, 0, 0.88);
}
.arrow {
position: absolute;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #fff; /* 箭头的颜色 */
left: 50%; /* 使箭头在中间 */
transform: translateX(-50%); /* 向左移动箭头宽度的一半 */
}
</style>

View File

@@ -1,351 +0,0 @@
<script setup lang="ts">
import {computed} from 'vue';
import {useSlotsExist} from '../Utils';
interface Gradient {
'0%'?: string
'100%'?: string
from?: string
to?: string
direction?: 'left' | 'right' // 默认 'right'
}
interface Props {
width?: number | string // 进度条总宽度,单位 px
percent?: number // 当前进度百分比
strokeWidth?: number // 进度条线的宽度,单位 px当 type: 'circle' 时,单位是进度圈画布宽度的百分比
strokeColor?: string | Gradient // 进度条的色彩,传入 string 时为纯色,传入 Gradient 时为渐变,进度圈时 direction: 'left' 为逆时针direction: 'right' 为顺时针
strokeLinecap?: 'round' | 'butt' | 'square' // 进度条的样式
showInfo?: boolean // 是否显示进度数值或状态图标
success?: string // 进度完成时的信息 string | slot
format?: (percent: number) => string | number // 内容的模板函数 function | slot
type?: 'line' | 'circle' // 进度条类型
}
const props = withDefaults(defineProps<Props>(), {
width: '100%',
percent: 0,
strokeWidth: 8,
strokeColor: '#1677FF',
strokeLinecap: 'round',
showInfo: true,
success: undefined,
format: (percent: number) => percent + '%',
type: 'line'
});
const slotsExist = useSlotsExist(['success']);
const totalWidth = computed(() => {
// 进度条总宽度
if (typeof props.width === 'number') {
return `${props.width}px`;
} else {
return props.width;
}
});
const perimeter = computed(() => {
// 圆条周长
return (100 - props.strokeWidth) * Math.PI;
});
const path = computed(() => {
// 圆条轨道路径指令
const long = 100 - props.strokeWidth;
return `M 50,50 m 0,-${long / 2}
a ${long / 2},${long / 2} 0 1 1 0,${long}
a ${long / 2},${long / 2} 0 1 1 0,-${long}`;
});
const gradientColor = computed(() => {
// 是否为渐变色
return typeof props.strokeColor !== 'string';
});
const lineColor = computed(() => {
if (typeof props.strokeColor === 'string') {
return props.strokeColor;
} else {
return `linear-gradient(to ${props.strokeColor.direction || 'right'}, ${props.strokeColor['0%'] || props.strokeColor.from}, ${props.strokeColor['100%'] || props.strokeColor.to})`;
}
});
const circleColorFrom = computed(() => {
if (gradientColor.value) {
const gradientColor = props.strokeColor as Gradient;
if (!gradientColor.direction || gradientColor.direction === 'right') {
return gradientColor['0%'] || gradientColor.from;
} else {
return gradientColor['100%'] || gradientColor.to;
}
}
return props.strokeColor;
});
const circleColorTo = computed(() => {
if (gradientColor.value) {
const gradientColor = props.strokeColor as Gradient;
if (!gradientColor.direction || gradientColor.direction === 'right') {
return gradientColor['100%'] || gradientColor.to;
} else {
return gradientColor['0%'] || gradientColor.from;
}
}
return props.strokeColor;
});
const showPercent = computed(() => {
return props.format(props.percent > 100 ? 100 : props.percent);
});
const showSuccess = computed(() => {
return slotsExist.success || props.success;
});
</script>
<template>
<div
v-if="type === 'line'"
class="m-progress-line"
:style="`width: ${totalWidth}; height: ${strokeWidth < 24 ? 24 : strokeWidth}px;`"
>
<div class="m-progress-inner">
<div
:class="['progress-bg', { 'line-success': percent >= 100 && !gradientColor }]"
:style="`background: ${lineColor}; width: ${percent >= 100 ? 100 : percent}%; height: ${strokeWidth}px; --border-radius: ${strokeLinecap === 'round' ? '100px' : 0};`"
></div>
</div>
<template v-if="showInfo">
<Transition name="fade" mode="out-in">
<span v-if="percent >= 100" class="progress-success">
<svg
v-if="showSuccess === undefined"
class="icon-svg"
focusable="false"
data-icon="check-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm193.5 301.7l-210.6 292a31.8 31.8 0 01-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z"
></path>
</svg>
<p v-else class="progress-success-info">
<slot name="success">{{ success }}</slot>
</p>
</span>
<p v-else class="progress-text">
<slot name="format" :percent="percent">{{ showPercent }}</slot>
</p>
</Transition>
</template>
</div>
<div v-else class="m-progress-circle" :style="`width: ${totalWidth}; height: ${totalWidth};`">
<svg class="progress-circle" viewBox="0 0 100 100">
<defs v-if="gradientColor">
<linearGradient id="circleGradient" x1="100%" y1="0%" x2="0%" y2="0%">
<stop offset="0%" :stop-color="circleColorFrom as string"></stop>
<stop offset="100%" :stop-color="circleColorTo as string"></stop>
</linearGradient>
</defs>
<path
:d="path"
:stroke-linecap="strokeLinecap"
class="circle-trail"
:stroke-width="strokeWidth"
:style="`stroke-dasharray: ${perimeter}px, ${perimeter}px;`"
fill-opacity="0"
></path>
<path
:d="path"
:stroke-linecap="strokeLinecap"
class="circle-path"
:class="{ 'circle-path-success': percent >= 100 && !gradientColor }"
:stroke-width="strokeWidth"
:stroke="gradientColor ? 'url(#circleGradient)' : lineColor"
:style="`stroke-dasharray: ${(percent / 100) * perimeter}px, ${perimeter}px;`"
:opacity="percent === 0 ? 0 : 1"
fill-opacity="0"
></path>
</svg>
<template v-if="showInfo">
<Transition name="fade" mode="out-in">
<svg
v-if="showSuccess === undefined && percent >= 100"
class="icon-svg"
focusable="false"
data-icon="check"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 00-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z"
></path>
</svg>
<p v-else-if="percent >= 100" class="progress-success-info">
<slot name="success">{{ success }}</slot>
</p>
<p v-else class="progress-text">
<slot name="format" :percent="percent">{{ showPercent }}</slot>
</p>
</Transition>
</template>
</div>
</template>
<style lang="less" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@success: #52c41a;
.m-progress-line {
display: flex;
align-items: center;
.m-progress-inner {
width: 100%;
background: rgba(0, 0, 0, 0.06);
border-radius: 100px;
overflow: hidden;
.progress-bg {
position: relative;
background-color: @themeColor;
border-radius: var(--border-radius);
transition: all 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
&::after {
content: '';
background-image: linear-gradient(90deg, rgba(255, 255, 255, 0.3) 0%, rgba(255, 255, 255, 0.5) 100%);
animation: progressRipple 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes progressRipple {
0% {
position: absolute;
inset: 0;
right: 100%;
opacity: 1;
}
66% {
position: absolute;
inset: 0;
opacity: 0;
}
100% {
position: absolute;
inset: 0;
opacity: 0;
}
}
}
.line-success {
background: @success !important;
}
}
.progress-success {
width: 40px;
text-align: center;
display: inline-flex;
align-items: center;
padding-left: 8px;
flex-shrink: 0; // 默认 1.即空间不足时,项目将缩小
.icon-svg {
display: inline-block;
font-size: 16px;
fill: currentColor;
color: @success;
}
.progress-success-info {
flex-shrink: 0; // 默认 1.即空间不足时,项目将缩小
width: 40px;
font-size: 14px;
padding-left: 8px;
color: @success;
}
}
.progress-text {
/*
如果所有项目的flex-shrink属性都为1当空间不足时都将等比例缩小
如果一个项目的flex-shrink属性为0其他项目都为1则空间不足时前者不缩小。
*/
flex-shrink: 0; // 默认 1.即空间不足时,项目将缩小
width: 40px;
font-size: 14px;
padding-left: 8px;
color: rgba(0, 0, 0, 0.88);
}
}
.m-progress-circle {
display: inline-block;
position: relative;
.progress-circle {
.circle-trail {
stroke: rgba(0, 0, 0, 0.06);
stroke-dashoffset: 0;
transition: stroke-dashoffset 0.3s ease 0s,
stroke-dasharray 0.3s ease 0s,
stroke 0.3s ease 0s,
stroke-width 0.06s ease 0.3s,
opacity 0.3s ease 0s;
}
.circle-path {
stroke-dashoffset: 0;
transition: stroke-dashoffset 0.3s ease 0s,
stroke-dasharray 0.3s ease 0s,
stroke 0.3s ease 0s,
stroke-width 0.06s ease 0.3s,
opacity 0.3s ease 0s;
}
.circle-path-success {
stroke: @success !important;
}
}
.icon-svg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: inline-block;
width: 30%;
height: 30%;
fill: currentColor;
color: @success;
}
.progress-success-info {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
font-size: 27px;
line-height: 1;
text-align: center;
color: @success;
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
font-size: 27px;
line-height: 1;
text-align: center;
color: rgba(0, 0, 0, 0.85);
}
}
</style>

View File

@@ -1,76 +0,0 @@
<script setup lang="ts">
import {computed} from 'vue';
import {useQRCode} from '@vueuse/integrations/useQRCode';
/*
参考文档https://vueuse.org/integrations/useQRCode/
https://www.npmjs.com/package/qrcode#qr-code-options
*/
interface Props {
value?: string // 扫描后的文本或地址
size?: number // 二维码大小,单位 px
color?: string // 二维码颜色Value must be in hex format (十六进制颜色值)
bgColor?: string // 二维码背景色Value must be in hex format (十六进制颜色值)
bordered?: boolean // 是否有边框
borderColor?: string // 边框颜色
scale?: number // 每个 black dots 多少像素
/*
纠错等级也叫纠错率,就是指二维码可以被遮挡后还能正常扫描,而这个能被遮挡的最大面积就是纠错率。
通常情况下二维码分为 4 个纠错级别L级 可纠正约 7% 错误、M级 可纠正约 15% 错误、Q级 可纠正约 25% 错误、H级 可纠正约30% 错误。
并不是所有位置都可以缺损,像最明显的三个角上的方框,直接影响初始定位。中间零散的部分是内容编码,可以容忍缺损。
当二维码的内容编码携带信息比较少的时候,也就是链接比较短的时候,设置不同的纠错等级,生成的图片不会发生变化。
*/
errorLevel?: 'L' | 'M' | 'Q' | 'H' // 二维码纠错等级
}
const props = withDefaults(defineProps<Props>(), {
value: undefined,
size: 160,
color: '#000',
bgColor: '#FFF',
bordered: true,
borderColor: '#0505050f',
scale: 8,
errorLevel: 'H' // 可选 L M Q H
});
const qrcode = computed(() => {
// `qrcode` will be a ref of data URL
return useQRCode(props.value || '', {
errorCorrectionLevel: props.errorLevel,
type: 'image/png',
quality: 1,
margin: 3,
scale: props.scale, // 8px per modules(black dots)
color: {
dark: props.color, // 像素点颜色
light: props.bgColor // 背景色
}
});
});
</script>
<template>
<div
class="m-qrcode"
:class="{ 'qrcode-bordered': bordered }"
:style="`width: ${size}px; height: ${size}px; border-color: ${borderColor};`"
>
<img :src="qrcode.value" class="qrcode-image" alt="QRCode"/>
</div>
</template>
<style lang="less" scoped>
.m-qrcode {
display: inline-block;
border-radius: 8px;
overflow: hidden;
.qrcode-image {
width: 100%;
height: 100%;
}
}
.qrcode-bordered {
border-width: 1px;
border-style: solid;
}
</style>

View File

@@ -1,396 +0,0 @@
<script setup lang="ts">
import {computed, ref, watchEffect} from 'vue';
interface Option {
label: string // 选项名
value: string | number | boolean // 选项值
disabled?: boolean // 是否禁用选项
}
interface Props {
options?: Option[] // 单选框选项数据
disabled?: boolean // 是否禁用
vertical?: boolean // 是否垂直排列,仅当 button: false 时生效
checked?: boolean // (v-model) 当前是否选中
gap?: number | number[] // 多个单选框之间的间距;垂直排列时为垂直间距,单位 px数组间距用于水平排列折行时[水平间距, 垂直间距];仅当 button: false 时生效
width?: string | number // 单选区域最大宽度,超出后折行,单位 px仅当 button: false 时生效
height?: string | number // 单选区域最大高度,超出后滚动,单位 px仅当 button: false 时生效
button?: boolean // 是否启用按钮样式
buttonStyle?: 'outline' | 'solid' // 按钮样式风格
buttonSize?: 'small' | 'middle' | 'large' // 按钮大小;仅当 button: true 时生效
value?: string | number | boolean // (v-model) 当前选中的值
}
const props = withDefaults(defineProps<Props>(), {
options: () => [],
disabled: false,
vertical: false,
checked: false,
gap: 8,
width: 'auto',
height: 'auto',
button: false,
buttonStyle: 'outline',
buttonSize: 'middle',
value: undefined
});
const radioChecked = ref<boolean>();
const optionsCheckedValue = ref<string | number | boolean>();
const emits = defineEmits(['update:checked', 'update:value', 'change']);
const optionsAmount = computed(() => {
// 选项总数
return props.options.length;
});
const maxWidth = computed(() => {
// 单选区域最大宽度
if (!props.button) {
if (typeof props.width === 'number') {
return `${props.width}px`;
} else {
return props.width;
}
} else {
return 'auto';
}
});
const maxHeight = computed(() => {
// 单选区域最大高度
if (!props.button) {
if (typeof props.height === 'number') {
return `${props.height}px`;
} else {
return props.height;
}
} else {
return 'auto';
}
});
const gapValue = computed(() => {
if (!props.button) {
if (!props.vertical && Array.isArray(props.gap)) {
return `${props.gap[1]}px ${props.gap[0]}px`;
}
return `${props.gap}px`;
} else {
return 0;
}
});
watchEffect(() => {
radioChecked.value = props.checked;
});
watchEffect(() => {
optionsCheckedValue.value = props.value;
});
function checkDisabled(disabled: boolean | undefined) {
if (disabled === undefined) {
return props.disabled;
} else {
return disabled;
}
}
function onClick(value: string | number | boolean) {
if (value !== optionsCheckedValue.value) {
optionsCheckedValue.value = value;
emits('update:value', value);
emits('change', value);
}
}
function onChecked() {
if (!radioChecked.value) {
radioChecked.value = true;
emits('update:checked', true);
emits('change', true);
}
}
</script>
<template>
<div
class="m-radio"
:class="{
'radio-vertical': !button && vertical,
'radio-button-solid': buttonStyle === 'solid',
'radio-button-small': button && buttonSize === 'small',
'radio-button-large': button && buttonSize === 'large'
}"
:style="`--radio-gap: ${gapValue}; --radio-max-width: ${maxWidth}; --radio-max-height: ${maxHeight};`"
>
<template v-if="optionsAmount">
<template v-if="!button">
<div
class="m-radio-wrap"
:class="{ 'radio-disabled': checkDisabled(option.disabled) }"
v-for="(option, index) in options"
:key="index"
@click="checkDisabled(option.disabled) ? () => false : onClick(option.value)"
>
<span class="radio-handle" :class="{ 'radio-checked': optionsCheckedValue === option.value }"></span>
<span class="radio-label">
<slot :label="option.label">{{ option.label }}</slot>
</span>
</div>
</template>
<template v-else>
<div
tabindex="0"
class="m-radio-button-wrap"
:class="{
'radio-button-checked': optionsCheckedValue === option.value,
'radio-button-disabled': checkDisabled(option.disabled)
}"
v-for="(option, index) in options"
:key="index"
@click="checkDisabled(option.disabled) ? () => false : onClick(option.value)"
>
<span class="radio-label">
<slot :label="option.label">{{ option.label }}</slot>
</span>
</div>
</template>
</template>
<template v-else>
<template v-if="!button">
<div class="m-radio-wrap" :class="{ 'radio-disabled': disabled }" @click="disabled ? () => false : onChecked()">
<span class="radio-handle" :class="{ 'radio-checked': radioChecked }"></span>
<span class="radio-label">
<slot></slot>
</span>
</div>
</template>
<template v-else>
<div
tabindex="0"
class="m-radio-button-wrap"
:class="{
'radio-button-checked': radioChecked,
'radio-button-disabled': disabled
}"
@click="disabled ? () => false : onChecked()"
>
<span class="radio-label">
<slot></slot>
</span>
</div>
</template>
</template>
</div>
</template>
<style lang="less" scoped>
.m-radio {
display: inline-flex;
flex-wrap: wrap;
gap: var(--radio-gap);
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
line-height: 1;
max-width: var(--radio-max-width);
max-height: var(--radio-max-height);
overflow: auto;
.m-radio-wrap {
display: inline-flex;
align-items: flex-start;
cursor: pointer;
&:not(.radio-disabled):hover {
.radio-handle {
border-color: @themeColor;
}
}
.radio-handle {
/*
如果所有项目的flex-shrink属性都为1当空间不足时都将等比例缩小
如果一个项目的flex-shrink属性为0其他项目都为1则空间不足时前者不缩小。
*/
flex-shrink: 0; // 默认 1.即空间不足时,项目将缩小
position: relative;
top: 3px;
width: 16px;
height: 16px;
background: transparent;
border: 1px solid #d9d9d9;
border-radius: 50%;
transition: all 0.3s;
&::after {
box-sizing: border-box;
position: absolute;
top: 50%;
left: 50%;
display: block;
width: 16px;
height: 16px;
margin-top: -8px;
margin-left: -8px;
background-color: #fff;
border-top: 0;
border-left: 0;
border-radius: 16px;
transform: scale(0);
opacity: 0;
transition: all 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
content: '';
}
}
.radio-checked {
border-color: @themeColor;
background-color: @themeColor;
&::after {
transform: scale(0.375);
opacity: 1;
transition: all 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
}
}
.radio-label {
word-break: break-all;
padding: 0 8px;
line-height: 1.5714285714285714;
}
}
.radio-disabled {
color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
.radio-handle {
background-color: rgba(0, 0, 0, 0.04);
border-color: #d9d9d9;
cursor: not-allowed;
&::after {
transform: scale(0.5);
background-color: rgba(0, 0, 0, 0.25);
}
}
}
.m-radio-button-wrap {
position: relative;
height: 32px;
padding-inline: 15px;
line-height: 30px;
background: #ffffff;
border: 1px solid #d9d9d9;
border-top-width: 1px;
border-left-width: 0;
border-right-width: 1px;
cursor: pointer;
transition: all 0.2s,
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&:focus-within {
box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
}
&:first-child {
border-left: 1px solid #d9d9d9;
border-start-start-radius: 6px;
border-end-start-radius: 6px;
}
&:not(:first-child)::before {
position: absolute;
top: -1px;
left: -1px;
display: block;
width: 1px;
height: 100%;
padding-block: 1px;
box-sizing: content-box;
background-color: #d9d9d9;
transition: background-color 0.3s;
content: '';
}
&:last-child {
border-start-end-radius: 6px;
border-end-end-radius: 6px;
}
&:not(.radio-button-disabled):hover {
color: @themeColor;
}
}
.radio-button-checked:not(.radio-button-disabled) {
z-index: 1;
color: @themeColor;
background-color: #ffffff;
border-color: @themeColor;
&::before {
background-color: @themeColor;
}
}
.radio-button-disabled {
color: rgba(0, 0, 0, 0.25);
background-color: rgba(0, 0, 0, 0.04);
border-color: #d9d9d9;
cursor: not-allowed;
}
.radio-button-disabled.radio-button-checked {
background-color: rgba(0, 0, 0, 0.15);
}
}
.radio-vertical {
flex-direction: column;
flex-wrap: nowrap;
}
.radio-button-solid {
.radio-button-checked:not(.radio-button-disabled) {
color: #fff;
background-color: @themeColor;
border-color: @themeColor;
&:hover {
color: #fff;
}
}
}
.radio-button-small {
.m-radio-button-wrap {
height: 24px;
padding-inline: 7px;
line-height: 22px;
&:first-child {
border-start-start-radius: 4px;
border-end-start-radius: 4px;
}
&:last-child {
border-start-end-radius: 4px;
border-end-end-radius: 4px;
}
}
}
.radio-button-large {
.m-radio-button-wrap {
height: 40px;
font-size: 16px;
line-height: 38px;
&:first-child {
border-start-start-radius: 8px;
border-end-start-radius: 8px;
}
&:last-child {
border-start-end-radius: 8px;
border-end-end-radius: 8px;
}
}
}
</style>

View File

@@ -1,7 +1,4 @@
<script setup lang="ts">
import {ref, watch} from 'vue';
import Tooltip from '../Tooltip/Tooltip.vue';
<script lang="ts">
interface Props {
allowClear?: boolean // 是否允许再次点击后清除
allowHalf?: boolean // 是否允许半选
@@ -15,6 +12,10 @@ interface Props {
tooltipProps?: object // Tooltip 组件属性配置,参考 Tooltip Props
value?: number // (v-model) 当前数,受控值 0,1,2,3...
}
</script>
<script setup lang="ts">
import {ref, watch} from 'vue';
import Tooltip from '../Tooltip/Tooltip.vue';
const props = withDefaults(defineProps<Props>(), {
allowClear: true,

File diff suppressed because it is too large Load Diff

View File

@@ -1,383 +0,0 @@
<script setup lang="ts">
import type {CSSProperties} from 'vue';
import {computed, onMounted, ref} from 'vue';
import {debounce, useEventListener, useMutationObserver} from '../Utils';
interface Props {
contentClass?: string // 内容 div 的类名
contentStyle?: CSSProperties // 内容 div 的样式
size?: number // 滚动条的大小,单位 px
trigger?: 'hover' | 'none' // 显示滚动条的时机,'none' 表示一直显示
autoHide?: boolean // 是否自动隐藏滚动条,仅当 trigger: 'hover' 时生效true: hover且不滚动时自动隐藏滚动时自动显示false: hover时始终显示
delay?: number // 滚动条自动隐藏的延迟时间,单位 ms
horizontal?: boolean // 是否使用横向滚动
}
const props = withDefaults(defineProps<Props>(), {
contentClass: undefined,
contentStyle: () => ({}),
size: 5,
trigger: 'hover',
autoHide: true,
delay: 1000,
horizontal: false
});
const scrollbarRef = ref();
const containerRef = ref();
const contentRef = ref();
const railVerticalRef = ref();
const railHorizontalRef = ref();
const showTrack = ref(false);
const containerScrollHeight = ref(0); // 滚动区域高度,包括溢出高度
const containerScrollWidth = ref(0); // 滚动区域宽度,包括溢出宽度
const containerClientHeight = ref(0); // 滚动区域高度,不包括溢出高度
const containerClientWidth = ref(0); // 滚动区域宽度,不包括溢出宽度
const containerHeight = ref(0); // 容器高度
const containerWidth = ref(0); // 容器宽度
const contentHeight = ref(0); // 内容高度
const contentWidth = ref(0); // 内容宽度
const railHeight = ref(0); // 滚动条高度
const railWidth = ref(0); // 滚动条宽度
const containerScrollTop = ref(0); // 垂直滚动距离
const containerScrollLeft = ref(0); // 水平滚动距离
const trackYPressed = ref(false); // 垂直滚动条是否被按下
const trackXPressed = ref(false); // 水平滚动条是否被按下
const mouseLeave = ref(false); // 鼠标在按下滚动条并拖动时是否离开滚动区域
const memoYTop = ref<number>(0); // 鼠标选中并按下垂直滚动条时已滚动的垂直距离
const memoXLeft = ref<number>(0); // 鼠标选中并按下水平滚动条时已滚动的水平距离
const memoMouseY = ref<number>(0); // 鼠标选中并按下垂直滚动条时的鼠标 Y 坐标
const memoMouseX = ref<number>(0); // 鼠标选中并按下水平滚动条时的鼠标 X 坐标
const horizontalContentStyle = {width: 'fit-content'}; // 水平滚动时内容区域默认样式
const trackHover = ref(false); // 鼠标是否在滚动条上
const trackLeave = ref(false); // 鼠标在按下滚动条并拖动时是否离开滚动条
const emit = defineEmits(['scroll']);
const autoShowTrack = computed(() => {
return props.trigger === 'hover' && props.autoHide;
});
const isYScroll = computed(() => {
// 是否存在垂直滚动
return containerScrollHeight.value > containerClientHeight.value;
});
const isXScroll = computed(() => {
// 是否存在水平滚动
return containerScrollWidth.value > containerClientWidth.value;
});
const isScroll = computed(() => {
// 是否存在滚动,水平或垂直
return isYScroll.value || (props.horizontal && isXScroll.value);
});
const trackHeight = computed(() => {
// 垂直滚动条高度
if (isYScroll.value) {
if (containerHeight.value && contentHeight.value && railHeight.value) {
const value = Math.min(
containerHeight.value,
(railHeight.value * containerHeight.value) / contentHeight.value + 1.5 * props.size
);
return Number(value.toFixed(4));
}
}
return 0;
});
const trackTop = computed(() => {
// 滚动条垂直偏移
if (containerHeight.value && contentHeight.value && railHeight.value) {
return (
(containerScrollTop.value / (contentHeight.value - containerHeight.value)) *
(railHeight.value - trackHeight.value)
);
}
return 0;
});
const trackWidth = computed(() => {
// 横向滚动条宽度
if (props.horizontal && isXScroll.value) {
if (containerWidth.value && contentWidth.value && railWidth.value) {
const value = (railWidth.value * containerWidth.value) / contentWidth.value + 1.5 * props.size;
return Number(value.toFixed(4));
}
}
return 0;
});
const trackLeft = computed(() => {
// 滚动条水平偏移
if (containerWidth.value && contentWidth.value && railWidth.value) {
return (
(containerScrollLeft.value / (contentWidth.value - containerWidth.value)) * (railWidth.value - trackWidth.value)
);
}
return 0;
});
useEventListener(window, 'resize', updateState);
const options = {childList: true, attributes: true, subtree: true};
useMutationObserver(scrollbarRef, updateState, options);
const debounceHideEvent = debounce(hideScrollbar, props.delay);
onMounted(() => {
updateState();
});
function hideScrollbar() {
if (!trackHover.value) {
showTrack.value = false;
}
}
function updateScrollState() {
containerScrollTop.value = containerRef.value.scrollTop;
containerScrollLeft.value = containerRef.value.scrollLeft;
}
function updateScrollbarState() {
containerScrollHeight.value = containerRef.value.scrollHeight;
containerScrollWidth.value = containerRef.value.scrollWidth;
containerClientHeight.value = containerRef.value.clientHeight;
containerClientWidth.value = containerRef.value.clientWidth;
containerHeight.value = containerRef.value.offsetHeight;
containerWidth.value = containerRef.value.offsetWidth;
contentHeight.value = contentRef.value.offsetHeight;
contentWidth.value = contentRef.value.offsetWidth;
railHeight.value = railVerticalRef.value.offsetHeight;
railWidth.value = railHorizontalRef.value.offsetWidth;
}
function updateState() {
updateScrollState();
updateScrollbarState();
}
function onScroll(e: Event) {
if (autoShowTrack.value) {
showTrack.value = true;
if (!trackXPressed.value && !trackYPressed.value) {
debounceHideEvent();
}
}
emit('scroll', e);
updateScrollState();
}
function onMouseEnter() {
if (trackXPressed.value || trackYPressed.value) {
mouseLeave.value = false;
} else {
if (!autoShowTrack.value) {
showTrack.value = true;
}
}
}
function onMouseLeave() {
if (trackXPressed.value || trackYPressed.value) {
mouseLeave.value = true;
} else {
if (!autoShowTrack.value) {
showTrack.value = false;
}
}
}
function onEnterTrack() {
trackHover.value = true;
}
function onLeaveTrack() {
if (trackXPressed.value || trackYPressed.value) {
trackLeave.value = true;
} else {
trackHover.value = false;
debounceHideEvent();
}
}
function onTrackVerticalMouseDown(e: MouseEvent) {
trackYPressed.value = true;
memoYTop.value = containerScrollTop.value;
memoMouseY.value = e.clientY;
window.onmousemove = (e: MouseEvent) => {
const diffY = e.clientY - memoMouseY.value;
const dScrollTop =
(diffY * (contentHeight.value - containerHeight.value)) / (containerHeight.value - trackHeight.value);
const toScrollTopUpperBound = contentHeight.value - containerHeight.value;
let toScrollTop = memoYTop.value + dScrollTop;
toScrollTop = Math.min(toScrollTopUpperBound, toScrollTop);
toScrollTop = Math.max(toScrollTop, 0);
containerRef.value.scrollTop = toScrollTop;
};
window.onmouseup = () => {
window.onmousemove = null;
trackYPressed.value = false;
if (props.trigger === 'hover' && mouseLeave.value) {
showTrack.value = false;
mouseLeave.value = false;
}
if (autoShowTrack.value && trackLeave.value) {
trackLeave.value = false;
trackHover.value = false;
debounceHideEvent();
}
};
}
function onTrackHorizontalMouseDown(e: MouseEvent) {
trackXPressed.value = true;
memoXLeft.value = containerScrollLeft.value;
memoMouseX.value = e.clientX;
window.onmousemove = (e: MouseEvent) => {
const diffX = e.clientX - memoMouseX.value;
const dScrollLeft =
(diffX * (contentWidth.value - containerWidth.value)) / (containerWidth.value - trackWidth.value);
const toScrollLeftUpperBound = contentWidth.value - containerWidth.value;
let toScrollLeft = memoXLeft.value + dScrollLeft;
toScrollLeft = Math.min(toScrollLeftUpperBound, toScrollLeft);
toScrollLeft = Math.max(toScrollLeft, 0);
containerRef.value.scrollLeft = toScrollLeft;
};
window.onmouseup = () => {
window.onmousemove = null;
trackXPressed.value = false;
if (props.trigger === 'hover' && mouseLeave.value) {
showTrack.value = false;
mouseLeave.value = false;
}
if (autoShowTrack.value && trackLeave.value) {
trackLeave.value = false;
trackHover.value = false;
debounceHideEvent();
}
};
}
function scrollTo(...args: any[]) {
containerRef.value?.scrollTo(...args);
}
function scrollBy(...args: any[]) {
containerRef.value?.scrollBy(...args);
}
defineExpose({
scrollTo,
scrollBy
});
</script>
<template>
<div
ref="scrollbarRef"
class="m-scrollbar"
:style="`--scrollbar-size: ${size}px;`"
@mouseenter="isScroll && trigger === 'hover' ? onMouseEnter() : () => false"
@mouseleave="isScroll && trigger === 'hover' ? onMouseLeave() : () => false"
>
<div ref="containerRef" class="scrollbar-container" @scroll="onScroll">
<div
ref="contentRef"
class="scrollbar-content"
:class="contentClass"
:style="[horizontal ? { ...horizontalContentStyle, ...contentStyle } : contentStyle]"
>
<slot></slot>
</div>
</div>
<div ref="railVerticalRef" class="scrollbar-rail rail-vertical">
<div
class="scrollbar-track"
:class="{ 'track-visible': trigger === 'none' || showTrack }"
:style="`top: ${trackTop}px; height: ${trackHeight}px;`"
@mouseenter="autoShowTrack ? onEnterTrack() : () => false"
@mouseleave="autoShowTrack ? onLeaveTrack() : () => false"
@mousedown.prevent.stop="onTrackVerticalMouseDown"
></div>
</div>
<div ref="railHorizontalRef" v-show="horizontal" class="scrollbar-rail rail-horizontal">
<div
class="scrollbar-track"
:class="{ 'track-visible': trigger === 'none' || showTrack }"
:style="`left: ${trackLeft}px; width: ${trackWidth}px;`"
@mouseenter="autoShowTrack ? onEnterTrack() : () => false"
@mouseleave="autoShowTrack ? onLeaveTrack() : () => false"
@mousedown.prevent.stop="onTrackHorizontalMouseDown"
></div>
</div>
</div>
</template>
<style lang="less" scoped>
.m-scrollbar {
overflow: hidden;
position: relative;
z-index: auto;
height: 100%;
width: 100%;
.scrollbar-container {
width: 100%;
overflow: scroll;
height: 100%;
min-height: inherit;
max-height: inherit;
scrollbar-width: none;
&::-webkit-scrollbar,
&::-webkit-scrollbar-track-piece,
&::-webkit-scrollbar-thumb {
width: 0;
height: 0;
display: none;
}
.scrollbar-content {
box-sizing: border-box;
min-width: 100%;
}
}
.scrollbar-rail {
position: absolute;
pointer-events: none;
user-select: none;
background: transparent;
-webkit-user-select: none;
.scrollbar-track {
z-index: 1;
position: absolute;
cursor: pointer;
opacity: 0;
pointer-events: none;
background-color: rgba(0, 0, 0, 0.25);
transition: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background-color: rgba(0, 0, 0, 0.4);
}
}
.track-visible {
opacity: 1;
pointer-events: all;
}
}
.rail-vertical {
inset: 2px 4px 2px auto;
width: var(--scrollbar-size);
.scrollbar-track {
width: var(--scrollbar-size);
border-radius: var(--scrollbar-size);
bottom: 0;
}
}
.rail-horizontal {
inset: auto 2px 4px 2px;
height: var(--scrollbar-size);
.scrollbar-track {
height: var(--scrollbar-size);
border-radius: var(--scrollbar-size);
right: 0;
}
}
}
</style>

View File

@@ -1,220 +0,0 @@
<script setup lang="ts">
interface SegmentedOption {
label?: string // 选项名
value: string | number // 选项值
disabled?: boolean // 是否禁用选项
payload?: any // 自定义数据载体
}
interface Props {
block?: boolean // 是否将宽度调整为父元素宽度,同时所有选项占据相同的宽度
disabled?: boolean // 是否禁用
options?: string[] | number[] | SegmentedOption[] // 选项数据
size?: 'small' | 'middle' | 'large' // 控件尺寸
value?: string | number // (v-model) 当前选中的值
}
const props = withDefaults(defineProps<Props>(), {
block: false,
disabled: false,
options: () => [],
size: 'middle',
value: undefined
});
const emits = defineEmits(['update:value', 'change']);
function onSelected(value: string | number) {
if (value !== props.value) {
emits('update:value', value);
emits('change', value);
}
}
function getOptionDisabled(option: string | number | SegmentedOption) {
if (typeof option == 'object') {
return option?.disabled || false;
}
return false;
}
function getOptionValue(option: string | number | SegmentedOption) {
if (typeof option == 'object') {
return option.value;
}
return option;
}
function getOptionLabel(option: string | number | SegmentedOption) {
if (typeof option == 'object') {
return option.label;
}
return option;
}
</script>
<template>
<div
class="m-segmented"
:class="{
'segmented-small': size == 'small',
'segmented-large': size == 'large',
'segmented-block': block
}"
>
<div class="m-segmented-group">
<div
class="m-segmented-item"
:class="{
'segmented-item-selected': value === getOptionValue(option),
'segmented-item-disabled': disabled || getOptionDisabled(option),
'segmented-item-block': block
}"
v-for="(option, index) in options"
:key="index"
@click="disabled || getOptionDisabled(option) ? () => false : onSelected(getOptionValue(option))"
>
<input
type="radio"
class="segmented-item-input"
:checked="value === getOptionValue(option)"
:disabled="disabled || getOptionDisabled(option)"
/>
<div
class="segmented-item-label"
:title="typeof option === 'object' && option.payload ? undefined : String(getOptionLabel(option))"
>
<slot
name="label"
:label="getOptionLabel(option)"
:payload="typeof option === 'object' ? option.payload : {}"
>
{{ getOptionLabel(option) }}
</slot>
</div>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.m-segmented {
display: inline-block;
padding: 2px;
color: rgba(0, 0, 0, 0.65);
font-size: 14px;
line-height: 1.5714285714285714;
background-color: #f5f5f5;
border-radius: 6px;
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
.m-segmented-group {
position: relative;
display: flex;
align-items: stretch;
justify-items: flex-start;
width: 100%;
.m-segmented-item {
position: relative;
text-align: center;
cursor: pointer;
transition: color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1),
background-color 0.2s;
border-radius: 4px;
&:hover:not(.segmented-item-selected):not(.segmented-item-disabled) {
color: rgba(0, 0, 0, 0.88);
&::after {
background-color: rgba(0, 0, 0, 0.06);
}
}
&::after {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
border-radius: inherit;
transition: background-color 0.2s;
pointer-events: none;
content: '';
}
.segmented-item-input {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.segmented-item-label {
min-height: 28px;
line-height: 28px;
padding: 0 11px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
:deep(svg) {
fill: currentColor;
}
}
}
.segmented-item-selected {
background-color: #ffffff;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03),
0 1px 6px -1px rgba(0, 0, 0, 0.02),
0 2px 4px 0 rgba(0, 0, 0, 0.02);
color: rgba(0, 0, 0, 0.88);
}
.segmented-item-disabled {
color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
}
}
}
.segmented-small {
border-radius: 4px;
.m-segmented-group .m-segmented-item {
border-radius: 2px;
.segmented-item-label {
min-height: 20px;
line-height: 20px;
padding: 0 7px;
}
}
}
.segmented-large {
border-radius: 8px;
.m-segmented-group .m-segmented-item {
border-radius: 6px;
.segmented-item-label {
min-height: 36px;
line-height: 36px;
padding: 0 11px;
font-size: 16px;
}
}
}
.segmented-block {
display: flex;
width: 100%;
.m-segmented-group .m-segmented-item {
flex: 1;
min-width: 0;
}
}
</style>

View File

@@ -1,547 +0,0 @@
<script setup lang="ts">
import { ref, computed, watchEffect, watch } from 'vue';
import Empty from '../Empty/Empty.vue';
import Scrollbar from '../Scrollbar/Scrollbar.vue';
interface Option {
label?: string // 选项名
value?: string | number // 选项值
disabled?: boolean // 是否禁用选项,默认 false
[propName: string]: any // 添加一个字符串索引签名,用于包含带有任意数量的其他属性
}
interface Props {
options?: Option[] // 选项数据
label?: string // 字典项的文本字段名
value?: string // 字典项的值字段名
placeholder?: string // 默认占位文本
disabled?: boolean // 是否禁用
allowClear?: boolean // 是否支持清除
search?: boolean // 是否支持搜索,使用搜索时请设置 width
/*
根据输入项进行筛选,默认为 true 时,筛选每个选项的文本字段 label 是否包含输入项,包含返回 true反之返回 false
当其为函数 Function 时,接受 inputValue option 两个参数,当 option 符合筛选条件时,应返回 true反之则返回 false
*/
filter?: any | true // 过滤条件函数,仅当支持搜索时生效
width?: string | number // 选择器宽度,单位 px
height?: number // 选择器高度,单位 px
maxDisplay?: number // 下拉面板最多能展示的下拉项数,超过后滚动显示
scrollbarProps?: object // 下拉面板滚动条 scrollbar 组件属性配置
modelValue?: number | string // (v-model) 当前选中的 option 条目值
}
const props = withDefaults(defineProps<Props>(), {
options: () => [],
label: 'label',
value: 'value',
placeholder: '请选择',
disabled: false,
search: false,
allowClear: false,
filter: true,
width: 'auto',
height: 32,
maxDisplay: 6,
scrollbarProps: () => ({}),
modelValue: undefined
});
const filterOptions = ref<Option[]>(); // 过滤后的选项数组
const selectedName = ref(); // 当前选中选项的 label
const inputRef = ref(); // 输入框 DOM 引用
const inputValue = ref(); // 支持搜索时,用户输入内容
const disabledBlur = ref(false); // 是否禁用 input 标签的 blur 事件
const hideSelectName = ref(false); // 用户输入时,隐藏 selectName 的展示
const hoverValue = ref(); // 鼠标悬浮项的 value 值
const showOptions = ref(false); // options 面板
const showArrow = ref(true); // 剪头图标显隐
const showClear = ref(false); // 清除图标显隐
const showCaret = ref(false); // 支持搜索时,输入光标的显隐
const showSearch = ref(false); // 搜索图标显隐
const selectFocused = ref(false); /// select 是否聚焦
const emits = defineEmits(['update:modelValue', 'change', 'openChange']);
const selectWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`;
}
return props.width;
});
const optionsStyle = computed(() => {
return {
maxHeight: `${props.maxDisplay * props.height + 8}px`
};
});
watchEffect(() => {
if (props.search) {
if (inputValue.value) {
showOptions.value = true;
filterOptions.value = props.options.filter((option) => {
if (typeof props.filter === 'function') {
return props.filter(inputValue.value, option);
} else {
return option[props.label].includes(inputValue.value);
}
});
} else {
filterOptions.value = [...props.options];
}
if (filterOptions.value.length && inputValue.value) {
hoverValue.value = filterOptions.value[0][props.value];
} else {
hoverValue.value = null;
}
} else {
filterOptions.value = props.options;
}
});
watchEffect(() => {
// 回调立即执行一次,同时会自动跟踪回调中所依赖的所有响应式依赖
initSelector();
});
watch(showOptions, (to) => {
emits('openChange', to);
if (props.search && !to) {
inputValue.value = undefined;
hideSelectName.value = false;
}
});
function initSelector() {
if (props.modelValue) {
const target = props.options.find((option) => option[props.value] === props.modelValue);
if (target) {
selectedName.value = target[props.label];
hoverValue.value = target[props.value];
} else {
selectedName.value = props.modelValue;
hoverValue.value = null;
}
} else {
selectedName.value = null;
hoverValue.value = null;
}
}
function onBlur() {
selectFocused.value = false;
if (showOptions.value) {
showOptions.value = false;
}
if (props.search) {
showSearch.value = false;
showArrow.value = true;
hideSelectName.value = false;
}
}
function onEnter() {
disabledBlur.value = true;
if (props.allowClear) {
if (selectedName.value || (props.search && inputValue.value)) {
showArrow.value = false;
showClear.value = true;
if (props.search) {
showSearch.value = false;
}
}
}
}
function onLeave() {
disabledBlur.value = false;
if (props.allowClear && showClear.value) {
showClear.value = false;
if (!props.search) {
showArrow.value = true;
}
}
if (props.search) {
if (showOptions.value) {
showSearch.value = true;
showArrow.value = false;
} else {
showSearch.value = false;
showArrow.value = true;
}
}
}
function onHover(value: string | number, disabled: boolean | undefined) {
disabledBlur.value = Boolean(disabled);
hoverValue.value = value;
}
function openSelect() {
selectFocus();
if (!props.search) {
inputRef.value.style.opacity = 0;
}
showOptions.value = !showOptions.value;
if (!hoverValue.value && selectedName.value) {
const target = props.options.find((option) => option[props.label] === selectedName.value);
hoverValue.value = target ? target[props.value] : null;
}
if (props.search) {
if (!showClear.value) {
showArrow.value = !showOptions.value;
showSearch.value = showOptions.value;
}
}
}
function onSearchInput(e: InputEvent) {
hideSelectName.value = Boolean((e.target as HTMLInputElement)?.value);
}
function onClear() {
if (selectFocused.value) {
selectFocus();
showCaret.value = true;
}
showClear.value = false;
selectedName.value = null;
hoverValue.value = null;
showOptions.value = false;
showSearch.value = false;
showArrow.value = true;
emits('update:modelValue');
emits('change');
}
function selectFocus() {
inputRef.value.focus(); // 通过 input 标签聚焦来模拟 select 整体聚焦效果
selectFocused.value = true;
}
function onChange(value: string | number, label: string, index: number) {
// 选中下拉项后的回调
if (props.modelValue !== value) {
selectedName.value = label;
hoverValue.value = value;
emits('update:modelValue', value);
emits('change', value, label, index);
}
showCaret.value = false;
}
</script>
<template>
<div
class="m-select"
:class="{ 'select-focused': selectFocused, 'search-select': search, 'select-disabled': disabled }"
:style="`width: ${selectWidth}; height: ${height}px;`"
@click="disabled ? () => false : openSelect()"
>
<div class="m-select-wrap" @mouseenter="onEnter" @mouseleave="onLeave">
<span class="m-select-search">
<Input
ref="inputRef"
class="select-search"
:class="{ 'caret-show': showOptions || showCaret }"
type="text"
autocomplete="off"
:readonly="!search"
:disabled="disabled"
@input="onSearchInput"
v-model="inputValue"
@blur="!disabledBlur && showOptions && !disabled ? onBlur() : () => false"
/>
</span>
<span
v-if="!hideSelectName"
class="select-item"
:class="{ 'select-placeholder': !selectedName || showOptions }"
:style="`line-height: ${height - 2}px;`"
:title="selectedName"
>
{{ selectedName || placeholder }}
</span>
<svg
class="arrow-svg"
:class="{ 'arrow-rotate': showOptions, 'show-svg': showArrow }"
focusable="false"
data-icon="down"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"
></path>
</svg>
<svg
class="search-svg"
:class="{ 'show-svg': showSearch }"
focusable="false"
data-icon="search"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M909.6 854.5L649.9 594.8C690.2 542.7 712 479 712 412c0-80.2-31.3-155.4-87.9-212.1-56.6-56.7-132-87.9-212.1-87.9s-155.5 31.3-212.1 87.9C143.2 256.5 112 331.8 112 412c0 80.1 31.3 155.5 87.9 212.1C256.5 680.8 331.8 712 412 712c67 0 130.6-21.8 182.7-62l259.7 259.6a8.2 8.2 0 0011.6 0l43.6-43.5a8.2 8.2 0 000-11.6zM570.4 570.4C528 612.7 471.8 636 412 636s-116-23.3-158.4-65.6C211.3 528 188 471.8 188 412s23.3-116.1 65.6-158.4C296 211.3 352.2 188 412 188s116.1 23.2 158.4 65.6S636 352.2 636 412s-23.3 116.1-65.6 158.4z"
></path>
</svg>
<svg
class="clear-svg"
:class="{ 'show-svg': showClear }"
focusable="false"
data-icon="close-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
fill-rule="evenodd"
viewBox="64 64 896 896"
@click.stop="onClear"
>
<path
d="M512 64c247.4 0 448 200.6 448 448S759.4 960 512 960 64 759.4 64 512 264.6 64 512 64zm127.98 274.82h-.04l-.08.06L512 466.75 384.14 338.88c-.04-.05-.06-.06-.08-.06a.12.12 0 00-.07 0c-.03 0-.05.01-.09.05l-45.02 45.02a.2.2 0 00-.05.09.12.12 0 000 .07v.02a.27.27 0 00.06.06L466.75 512 338.88 639.86c-.05.04-.06.06-.06.08a.12.12 0 000 .07c0 .03.01.05.05.09l45.02 45.02a.2.2 0 00.09.05.12.12 0 00.07 0c.02 0 .04-.01.08-.05L512 557.25l127.86 127.87c.04.04.06.05.08.05a.12.12 0 00.07 0c.03 0 .05-.01.09-.05l45.02-45.02a.2.2 0 00.05-.09.12.12 0 000-.07v-.02a.27.27 0 00-.05-.06L557.25 512l127.87-127.86c.04-.04.05-.06.05-.08a.12.12 0 000-.07c0-.03-.01-.05-.05-.09l-45.02-45.02a.2.2 0 00-.09-.05.12.12 0 00-.07 0z"
></path>
</svg>
</div>
<Transition name="slide-up">
<div
v-if="showOptions && filterOptions && filterOptions.length"
class="m-options-panel"
:style="`top: ${height + 4}px;`"
@mouseleave="disabledBlur = false"
>
<Scrollbar :content-style="{ padding: '4px' }" :style="optionsStyle" v-bind="scrollbarProps">
<p
v-for="(option, index) in filterOptions"
:key="index"
:class="[
'select-option',
{
'option-hover': !option.disabled && option[value] === hoverValue,
'option-selected': option[label] === selectedName,
'option-disabled': option.disabled
}
]"
:title="option[label]"
@mouseenter="onHover(option[value], option.disabled)"
@click.stop="option.disabled ? selectFocus() : onChange(option[value], option[label], index)"
>
{{ option[label] }}
</p>
</Scrollbar>
</div>
<div
v-else-if="showOptions && filterOptions && !filterOptions.length"
class="options-empty"
:style="`top: ${height + 4}px; width: ${width}px;`"
>
<Empty image="outlined" />
</div>
</Transition>
</div>
</template>
<style lang="less" scoped>
.slide-up-enter-active {
transform: scaleY(1);
transform-origin: 0% 0%;
opacity: 1;
transition: all 0.2s cubic-bezier(0.23, 1, 0.32, 1);
}
.slide-up-leave-active {
transform: scaleY(1);
transform-origin: 0% 0%;
opacity: 1;
transition: all 0.2s cubic-bezier(0.755, 0.05, 0.855, 0.06);
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: scaleY(0.8);
transform-origin: 0% 0%;
opacity: 0;
}
.m-select {
position: relative;
display: inline-block;
font-size: 14px;
font-weight: 400;
color: rgba(0, 0, 0, 0.88);
outline: none;
cursor: pointer;
&:not(.select-disabled):hover {
// 悬浮时样式
.m-select-wrap {
border-color: #4096ff;
}
}
.m-select-wrap {
position: relative;
display: flex;
padding: 0 11px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background-color: #fff;
width: 100%;
height: 100%;
outline: none;
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
.m-select-search {
position: absolute;
top: 0;
bottom: 0;
left: 11px;
right: 11px;
.select-search {
height: 100%;
margin: 0;
padding: 0;
width: 100%;
caret-color: transparent;
background: transparent;
border: none;
outline: none;
appearance: none;
opacity: 0;
cursor: pointer;
}
.caret-show {
caret-color: auto;
}
}
.select-item {
position: relative;
padding-right: 18px;
flex: 1;
user-select: none;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: all 0.3s;
}
.select-placeholder {
color: rgba(0, 0, 0, 0.25);
transition: none;
pointer-events: none;
}
.icon-svg {
position: absolute;
top: 0;
bottom: 0;
right: 11px;
margin: auto 0;
display: inline-block;
font-size: 12px;
color: rgba(0, 0, 0, 0.25);
fill: currentColor;
opacity: 0;
user-select: none;
pointer-events: none;
}
.arrow-svg {
.icon-svg();
transition:
transform 0.3s,
opacity 0.3s;
}
.arrow-rotate {
transform: rotate(180deg);
}
.search-svg {
.icon-svg();
transition: opacity 0.3s;
}
.clear-svg {
.icon-svg();
z-index: 1;
background: #fff;
cursor: pointer;
transition:
color 0.2s,
opacity 0.3s;
&:hover {
color: rgba(0, 0, 0, 0.45);
}
}
.show-svg {
opacity: 1;
pointer-events: auto;
}
}
.m-options-panel {
position: absolute;
z-index: 1000;
width: 100%;
background-color: #fff;
border-radius: 8px;
outline: none;
box-shadow:
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
.select-option {
// 下拉项默认样式
min-height: 32px;
display: block;
padding: 5px 12px;
border-radius: 4px;
color: rgba(0, 0, 0, 0.88);
font-weight: 400;
font-size: 14px;
line-height: 1.5714285714285714;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: background 0.3s ease;
}
.option-hover {
// 悬浮时的下拉项样式
background: rgba(0, 0, 0, 0.04);
}
.option-selected {
// 被选中的下拉项样式
font-weight: 600;
background: #e6f4ff;
}
.option-disabled {
// 禁用某个下拉选项时的样式
color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
}
}
.options-empty {
position: absolute;
z-index: 1000;
border-radius: 8px;
padding: 9px 16px;
background-color: #fff;
outline: none;
box-shadow:
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
.m-empty {
margin-block: 8px;
:deep(.m-empty-image) {
height: 35px;
}
}
}
}
.select-focused:not(.select-disabled) {
// 激活时样式
.m-select-wrap {
border-color: #4096ff;
box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
}
}
.search-select {
.m-select-wrap {
cursor: text;
.m-select-search {
.select-search {
cursor: auto;
color: inherit;
opacity: 1;
}
}
}
}
.select-disabled {
.m-select-wrap {
// 下拉禁用样式
color: rgba(0, 0, 0, 0.25);
background: #f5f5f5;
user-select: none;
cursor: not-allowed;
.m-select-search .select-search {
cursor: not-allowed;
}
}
}
</style>

View File

@@ -1,352 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
interface SkeletonButtonProps {
shape?: 'default' | 'round' | 'circle' // 指定按钮的形状,默认 'default'
size?: 'small' | 'middle' | 'large' // 设置按钮的大小,默认 'middle'
block?: boolean // 将按钮宽度调整为其父宽度的选项,默认 false
}
interface SkeletonAvatarProps {
shape?: 'circle' | 'square' // 指定头像的形状,默认 'circle'
size?: number | 'small' | 'middle' | 'large' // 设置头像占位图的大小,默认 'middle'
}
interface SkeletonInputProps {
size: 'small' | 'middle' | 'large' // 设置输入框的大小,默认 'middle'
}
interface SkeletonTitleProps {
width?: number | string // 设置标题占位图的宽度,默认 '38%'
}
interface SkeletonParagraphProps {
rows?: number | string // 设置段落占位图的行数,默认 avatar ? 2 : 3
width?: number | string | Array<number | string> // 设置段落占位图的宽度,若为数组时则为对应的每行宽度,反之则是最后一行的宽度,默认 '61%'
}
interface Props {
animated?: boolean // 是否展示动画效果
button?: boolean | SkeletonButtonProps // 是否使用按钮占位图
avatar?: boolean | SkeletonAvatarProps // 是否显示头像占位图
input?: boolean | SkeletonInputProps // 是否使用输入框占位图
image?: boolean // 是否使用图像占位图
title?: boolean | SkeletonTitleProps // 是否显示标题占位图
paragraph?: boolean | SkeletonParagraphProps // 是否显示段落占位图
loading?: boolean // 为 true 时,显示占位图,反之则直接展示子组件
}
const props = withDefaults(defineProps<Props>(), {
animated: true,
button: false,
image: false,
avatar: false,
input: false,
title: true,
paragraph: true,
loading: true
});
const buttonSize = computed(() => {
if (typeof props.button === 'object') {
if (props.button.size === 'large') {
return 40;
}
if (props.button.size === 'small') {
return 24;
}
return 32;
}
return 32;
});
const titleTop = computed(() => {
if (typeof props.avatar === 'boolean') {
return 8;
} else {
if (typeof props.avatar.size === 'number') {
return (props.avatar.size - 16) / 2;
} else {
const topMap = {
small: 4,
middle: 8,
large: 12
};
return topMap[props.avatar.size || 'middle'];
}
}
});
const titleWidth = computed(() => {
if (typeof props.title === 'boolean') {
return '38%';
} else {
if (typeof props.title.width === 'number') {
return `${props.title.width}px`;
}
return props.title.width || '38%';
}
});
const paragraphRows = computed(() => {
if (typeof props.paragraph === 'boolean') {
if (props.avatar) {
return 2;
} else {
return 3;
}
} else {
if (props.avatar) {
return props.paragraph.rows || 2;
} else {
return props.paragraph.rows || 3;
}
}
});
const paragraphWidth = computed(() => {
if (typeof props.paragraph === 'object') {
if (Array.isArray(props.paragraph.width)) {
return props.paragraph.width.map((width: number | string) => {
if (typeof width === 'number') {
return `${width}px`;
} else {
return width;
}
});
} else if (typeof props.paragraph.width === 'number') {
return Array(paragraphRows.value).fill(`${props.paragraph.width}px`);
} else if (typeof props.paragraph.width === 'string') {
return Array(paragraphRows.value).fill(props.paragraph.width);
}
}
return Array(paragraphRows.value);
});
</script>
<template>
<div
v-if="loading"
class="m-skeleton"
:class="{ 'skeleton-avatar': avatar, 'skeleton-animated': animated }"
:style="`--button-size: ${buttonSize}px; --title-top: ${titleTop}px;`"
>
<span
v-if="button"
class="skeleton-button"
:class="{
'button-round': typeof button !== 'boolean' && button.shape === 'round',
'button-circle': typeof button !== 'boolean' && button.shape === 'circle',
'button-sm': typeof button !== 'boolean' && button.size === 'small',
'button-lg': typeof button !== 'boolean' && button.size === 'large',
'button-block': typeof button !== 'boolean' && button.shape !== 'circle' && button.block
}"
></span>
<span
v-if="input"
class="skeleton-input"
:class="{
'input-sm': typeof input !== 'boolean' && input.size === 'small',
'input-lg': typeof input !== 'boolean' && input.size === 'large'
}"
></span>
<div v-if="image" class="skeleton-image">
<svg class="image-svg" viewBox="0 0 1098 1024" xmlns="http://www.w3.org/2000/svg">
<path
class="svg-path"
d="M365.714286 329.142857q0 45.714286-32.036571 77.677714t-77.677714 32.036571-77.677714-32.036571-32.036571-77.677714 32.036571-77.677714 77.677714-32.036571 77.677714 32.036571 32.036571 77.677714zM950.857143 548.571429l0 256-804.571429 0 0-109.714286 182.857143-182.857143 91.428571 91.428571 292.571429-292.571429zM1005.714286 146.285714l-914.285714 0q-7.460571 0-12.873143 5.412571t-5.412571 12.873143l0 694.857143q0 7.460571 5.412571 12.873143t12.873143 5.412571l914.285714 0q7.460571 0 12.873143-5.412571t5.412571-12.873143l0-694.857143q0-7.460571-5.412571-12.873143t-12.873143-5.412571zM1097.142857 164.571429l0 694.857143q0 37.741714-26.843429 64.585143t-64.585143 26.843429l-914.285714 0q-37.741714 0-64.585143-26.843429t-26.843429-64.585143l0-694.857143q0-37.741714 26.843429-64.585143t64.585143-26.843429l914.285714 0q37.741714 0 64.585143 26.843429t26.843429 64.585143z"
></path>
</svg>
</div>
<div v-if="avatar" class="skeleton-header">
<span
class="skeleton-avatar"
:class="{
'avatar-sm': typeof avatar !== 'boolean' && avatar.size === 'small',
'avatar-lg': typeof avatar !== 'boolean' && avatar.size === 'large',
'avatar-square': typeof avatar !== 'boolean' && avatar.shape === 'square'
}"
></span>
</div>
<template v-if="!button && !image && !input">
<div v-if="title || paragraph" class="skeleton-content">
<h3 v-if="title" class="skeleton-title" :style="{ width: titleWidth }"></h3>
<ul v-if="paragraph" class="skeleton-paragraph" :class="{ mt24: title, mt28: title && avatar }">
<li v-for="n in paragraphRows" :key="n" :style="`width: ${paragraphWidth[(n as number) - 1]};`"></li>
</ul>
</div>
</template>
</div>
<slot v-else></slot>
</template>
<style lang="less" scoped>
.m-skeleton {
display: table;
width: 100%;
.skeleton-button {
display: inline-block;
vertical-align: top;
background: rgba(0, 0, 0, 0.06);
border-radius: 4px;
width: 64px;
min-width: 64px;
height: 32px;
line-height: 32px;
}
.button-sm {
width: 48px;
min-width: 48px;
height: 24px;
line-height: 24px;
}
.button-lg {
width: 80px;
min-width: 80px;
height: 40px;
line-height: 40px;
}
.button-round {
border-radius: var(--button-size);
}
.button-circle {
width: var(--button-size);
min-width: var(--button-size);
border-radius: 50%;
}
.button-block {
width: 100%;
}
.skeleton-input {
display: inline-block;
vertical-align: top;
background: rgba(0, 0, 0, 0.06);
border-radius: 4px;
width: 160px;
min-width: 160px;
height: 32px;
line-height: 32px;
}
.input-sm {
width: 120px;
min-width: 120px;
height: 24px;
line-height: 24px;
}
.input-lg {
width: 200px;
min-width: 200px;
height: 40px;
line-height: 40px;
}
.skeleton-image {
display: flex;
align-items: center;
justify-content: center;
vertical-align: top;
background: rgba(0, 0, 0, 0.06);
border-radius: 4px;
width: 96px;
height: 96px;
line-height: 96px;
.image-svg {
width: 48px;
height: 48px;
line-height: 48px;
max-width: 192px;
max-height: 192px;
color: #bfbfbf;
.svg-path {
fill: currentColor;
}
}
}
.skeleton-header {
display: table-cell;
padding-right: 16px;
vertical-align: top;
.skeleton-avatar {
display: inline-block;
vertical-align: top;
background: rgba(0, 0, 0, 0.06);
width: 32px;
height: 32px;
line-height: 32px;
border-radius: 50%;
}
.avatar-sm {
width: 24px;
height: 24px;
line-height: 24px;
}
.avatar-lg {
width: 40px;
height: 40px;
line-height: 40px;
}
.avatar-square {
border-radius: 6px;
}
}
.skeleton-content {
display: table-cell;
width: 100%;
vertical-align: top;
.skeleton-title {
margin: 0;
height: 16px;
background: rgba(0, 0, 0, 0.06);
border-radius: 4px;
}
.skeleton-paragraph {
margin: 0;
padding: 0;
li {
height: 16px;
list-style: none;
background: rgba(0, 0, 0, 0.06);
border-radius: 4px;
&:not(:first-child) {
margin-top: 16px;
}
&:last-child {
width: 61%;
}
}
}
.mt24 {
margin-top: 24px;
}
.mt28 {
margin-top: 28px;
}
}
}
.skeleton-avatar {
.skeleton-content {
.skeleton-title {
margin-top: var(--title-top);
}
}
}
.skeleton-animated {
.skeleton-button,
.skeleton-input,
.skeleton-image,
.skeleton-header .skeleton-avatar,
.skeleton-content .skeleton-title,
.skeleton-content .skeleton-paragraph li {
position: relative;
z-index: 0;
overflow: hidden;
background: transparent;
&::after {
position: absolute;
top: 0;
left: -150%;
bottom: 0;
right: -150%;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.06) 25%, rgba(0, 0, 0, 0.15) 37%, rgba(0, 0, 0, 0.06) 63%);
animation-name: skeleton-loading;
animation-duration: 1.4s;
animation-timing-function: ease;
animation-iteration-count: infinite;
content: '';
}
@keyframes skeleton-loading {
0% {
transform: translateX(-37.5%);
}
100% {
transform: translateX(37.5%);
}
}
}
}
</style>

View File

@@ -1,712 +0,0 @@
<script setup lang="ts">
import {computed, onMounted, ref, watch} from 'vue';
import {useResizeObserver} from '../Utils';
interface Props {
width?: string | number // 滑动输入条宽度,单位 px水平模式时生效
height?: string | number // 滑动输入条高度,单位 px垂直模式时生效
vertical?: boolean // 是否启用垂直模式
min?: number // 最小值
max?: number // 最大值
disabled?: boolean // 是否禁用
range?: boolean // 是否使用双滑块模式
step?: number // 步长,取值必须大于 0并且可被 (max - min) 整除
formatTooltip?: (value: number) => string | number // Slider 会把当前值传给 formatTooltip并在 Tooltip 中显示 formatTooltip 的返回值
tooltip?: boolean // 是否展示 Tooltip
value?: number | number[] // (v-model) 设置当前取值,当 range 为 false 时,使用 number否则用 [number, number]
}
const props = withDefaults(defineProps<Props>(), {
width: '100%',
height: '100%',
vertical: false,
min: 0,
max: 100,
disabled: false,
range: false,
step: 1,
formatTooltip: (value: number) => value,
tooltip: true,
value: 0
});
const sliderRef = ref(); // slider DOM 引用
const sliderWidth = ref(); // 滑动输入条宽度
const sliderHeight = ref(); // 滑动输入条高度
const low = ref(0); // 左/下滑块距离滑动条左/上端的距离
const high = ref(0); // 右/上滑动距离滑动条左/上端的距离
const lowHandle = ref(); // low handle 模板引用
const lowTooltip = ref(); // low tooltip 模板应用
const highHandle = ref(); // high handle 模板引用
const highTooltip = ref(); // high tooltip 模板引用
const emits = defineEmits(['update:value', 'change']);
const sliderSize = computed(() => {
if (!props.vertical) {
return sliderWidth.value;
} else {
return sliderHeight.value;
}
});
const sliderStyle = computed(() => {
if (!props.vertical) {
return {
width: typeof props.width === 'number' ? `${props.width}px` : props.width
};
} else {
return {
height: typeof props.height === 'number' ? `${props.height}px` : props.height
};
}
});
const trackStyle = computed(() => {
if (!props.vertical) {
return {
left: `${low.value}px`,
right: 'auto',
width: `${high.value - low.value}px`
};
} else {
return {
bottom: `${low.value}px`,
top: 'auto',
height: `${high.value - low.value}px`
};
}
});
const lowHandleStyle = computed(() => {
if (!props.vertical) {
return {
left: `${low.value}px`,
right: 'auto',
transform: 'translate(-50%, -50%)'
};
} else {
return {
bottom: `${low.value}px`,
top: 'auto',
transform: 'translate(-50%, 50%)'
};
}
});
const highHandleStyle = computed(() => {
if (!props.vertical) {
return {
left: `${high.value}px`,
right: 'auto',
transform: 'translate(-50%, -50%)'
};
} else {
return {
bottom: `${high.value}px`,
top: 'auto',
transform: 'translate(-50%, 50%)'
};
}
});
const precision = computed(() => {
// 获取 step 数值精度
const strNumArr = props.step.toString().split('.');
return strNumArr[1]?.length ?? 0;
});
const sliderValue = computed(() => {
let highValue;
if (high.value === sliderSize.value) {
highValue = props.max;
} else {
highValue = fixedDigit(pixelStepOperation(high.value, '/') * props.step + props.min, precision.value);
if (props.step > 1) {
highValue = Math.round(highValue / props.step) * props.step;
}
}
if (props.range) {
let lowValue = fixedDigit(pixelStepOperation(low.value, '/') * props.step + props.min, precision.value);
if (props.step > 1) {
lowValue = Math.round(lowValue / props.step) * props.step;
}
return [lowValue, highValue];
}
return highValue;
});
const lowTooltipValue = computed(() => {
if (props.range) {
return props.formatTooltip((sliderValue.value as number[])[0]);
}
return null;
});
const highTooltipValue = computed(() => {
if (props.range) {
return props.formatTooltip((sliderValue.value as number[])[1]);
}
return props.formatTooltip(sliderValue.value as number);
});
watch(
() => [props.min, props.max, props.step, props.vertical, props.value],
() => {
getSliderPosition();
},
{
deep: true
}
);
watch(sliderValue, (to) => {
emits('update:value', to);
emits('change', to);
});
useResizeObserver(sliderRef, () => {
getSliderSize();
getSliderPosition();
});
onMounted(() => {
getSliderSize();
getSliderPosition();
});
function getSliderSize() {
sliderWidth.value = sliderRef.value.offsetWidth;
sliderHeight.value = sliderRef.value.offsetHeight;
}
function getSliderPosition() {
if (props.range) {
// 双滑块模式
const lowValue = pixelStepOperation((checkLow((props.value as number[])[0]) - props.min) / props.step, '*');
low.value = fixedDigit(lowValue, 2);
const highValue = pixelStepOperation((checkHigh((props.value as number[])[1]) - props.min) / props.step, '*');
high.value = fixedDigit(highValue, 2);
} else {
const highValue = pixelStepOperation((checkValue(props.value as number) - props.min) / props.step, '*');
high.value = fixedDigit(highValue, 2);
}
}
function checkLow(value: number): number {
if (value < props.min) {
return props.min;
}
return value;
}
function checkHigh(value: number): number {
if (value > props.max) {
return props.max;
}
return value;
}
function checkValue(value: number): number {
if (value < props.min) {
return props.min;
}
if (value > props.max) {
return props.max;
}
return value;
}
function fixedDigit(num: number, precision: number) {
return parseFloat(num.toFixed(precision));
}
function handlerBlur(tooltip: HTMLElement) {
tooltip.classList.remove('show-handle-tooltip');
}
function handlerFocus(handler: HTMLElement, tooltip: HTMLElement) {
handler.focus();
if (props.tooltip) {
tooltip.classList.add('show-handle-tooltip');
}
}
function onClickSliderPoint(e: MouseEvent) {
let targetDistance; // 目标移动距离
if (!props.vertical) {
// horizontal
const leftX = sliderRef.value.getBoundingClientRect().left; // 滑动条左端距离屏幕可视区域左边界的距离
// e.clientX 返回事件被触发时鼠标指针相对于浏览器可视窗口的水平坐标
const value = Math.round(pixelStepOperation(e.clientX - leftX, '/'));
targetDistance = fixedDigit(pixelStepOperation(value, '*'), 2);
} else {
const bottomY = sliderRef.value.getBoundingClientRect().bottom; // 滑动条下端距离屏幕可视区域上边界的距离
// e.clientY 返回事件被触发时鼠标指针相对于浏览器可视窗口的垂直坐标
const value = Math.round(pixelStepOperation(bottomY - e.clientY, '/'));
targetDistance = fixedDigit(pixelStepOperation(value, '*'), 2);
}
if (props.range) {
// 双滑块模式
if (targetDistance <= low.value) {
low.value = targetDistance;
handlerFocus(lowHandle.value, lowTooltip.value);
} else if (targetDistance >= high.value) {
high.value = targetDistance;
handlerFocus(highHandle.value, highTooltip.value);
} else {
if (targetDistance - low.value < high.value - targetDistance) {
low.value = targetDistance;
handlerFocus(lowHandle.value, lowTooltip.value);
} else {
high.value = targetDistance;
handlerFocus(highHandle.value, highTooltip.value);
}
}
} else {
// 单滑块模式
high.value = targetDistance;
handlerFocus(highHandle.value, highTooltip.value);
}
}
function onLowMouseDown() {
// 在滑动输入条上拖动较小数值滑块
let originalDistance;
if (!props.vertical) {
// horizontal
originalDistance = sliderRef.value.getBoundingClientRect().left; // 滑动条左端距离屏幕可视区域左边界的距离
} else {
originalDistance = sliderRef.value.getBoundingClientRect().bottom; // 滑动条下端距离屏幕可视区域上边界的距离
}
window.onmousemove = (e: MouseEvent) => {
if (props.tooltip) {
lowTooltip.value.classList.add('show-handle-tooltip');
}
let targetDistance; // 目标移动距离
if (!props.vertical) {
// horizontal
// e.clientX 返回事件被触发时鼠标指针相对于浏览器可视窗口的水平坐标
const value = Math.round(pixelStepOperation(e.clientX - originalDistance, '/'));
targetDistance = fixedDigit(pixelStepOperation(value, '*'), 2);
} else {
// vertical
// e.clientY 返回事件被触发时鼠标指针相对于浏览器可视窗口的垂直坐标
const value = Math.round(pixelStepOperation(originalDistance - e.clientY, '/'));
targetDistance = fixedDigit(pixelStepOperation(value, '*'), 2);
}
if (targetDistance < 0) {
low.value = 0;
} else if (targetDistance >= 0 && targetDistance <= high.value) {
low.value = targetDistance;
} else {
// targetDistance > high
low.value = high.value;
highHandle.value.focus();
onHighMouseDown();
}
};
window.onmouseup = () => {
if (props.tooltip) {
lowTooltip.value.classList.remove('show-handle-tooltip');
}
window.onmousemove = null;
};
}
function onHighMouseDown() {
// 在滑动输入条上拖动较大数值滑块
let originalDistance;
if (!props.vertical) {
// horizontal
originalDistance = sliderRef.value.getBoundingClientRect().left; // 滑动条左端距离屏幕可视区域左边界的距离
} else {
originalDistance = sliderRef.value.getBoundingClientRect().bottom; // 滑动条下端距离屏幕可视区域上边界的距离
}
window.onmousemove = (e: MouseEvent) => {
if (props.tooltip) {
highTooltip.value.classList.add('show-handle-tooltip');
}
let targetDistance; // 目标移动距离
if (!props.vertical) {
// horizontal
// e.clientX 返回事件被触发时鼠标指针相对于浏览器可视窗口的水平坐标
const value = Math.round(pixelStepOperation(e.clientX - originalDistance, '/'));
targetDistance = fixedDigit(pixelStepOperation(value, '*'), 2);
} else {
// vertical
// e.clientY 返回事件被触发时鼠标指针相对于浏览器可视窗口的垂直坐标
const value = Math.round(pixelStepOperation(originalDistance - e.clientY, '/'));
targetDistance = fixedDigit(pixelStepOperation(value, '*'), 2);
}
if (targetDistance > sliderSize.value) {
high.value = sliderSize.value;
} else if (low.value <= targetDistance && targetDistance <= sliderSize.value) {
high.value = targetDistance;
} else {
// targetDistance < low
high.value = low.value;
if (props.range) {
lowHandle.value.focus();
onLowMouseDown();
}
}
};
window.onmouseup = () => {
if (props.tooltip) {
highTooltip.value.classList.remove('show-handle-tooltip');
}
window.onmousemove = null;
};
}
function onLowSlide(source: number, place: string) {
const targetDistance = pixelStepOperation(source, '-');
if (place === 'low') {
// 左/下滑块左/下移
if (targetDistance < 0) {
low.value = 0;
} else {
low.value = targetDistance;
}
} else {
// 右/上滑块左/下移
if (targetDistance >= low.value) {
high.value = targetDistance;
} else {
high.value = low.value;
low.value = targetDistance;
lowHandle.value.focus();
}
}
}
function onHighSlide(source: number, place: string) {
const targetDistance = pixelStepOperation(source, '+');
if (place === 'high') {
// 右/上滑块右/上移
if (targetDistance > sliderSize.value) {
high.value = sliderSize.value;
} else {
high.value = targetDistance;
}
} else {
// 左/下滑块右/上移
if (targetDistance <= high.value) {
low.value = targetDistance;
} else {
low.value = high.value;
high.value = targetDistance;
highHandle.value.focus();
}
}
}
function pixelStepOperation(target: number, operator: '+' | '-' | '*' | '/'): number {
if (operator === '+') {
return target + (sliderSize.value * props.step) / (props.max - props.min);
}
if (operator === '-') {
return target - (sliderSize.value * props.step) / (props.max - props.min);
}
if (operator === '*') {
return (target * sliderSize.value * props.step) / (props.max - props.min);
}
if (operator === '/') {
return (target * (props.max - props.min)) / (sliderSize.value * props.step);
}
return target;
}
</script>
<template>
<div
ref="sliderRef"
class="m-slider"
:class="{
'slider-horizontal': !vertical,
'slider-vertical': vertical,
'slider-disabled': disabled
}"
:style="sliderStyle"
@click="disabled ? () => false : onClickSliderPoint($event)"
>
<div class="slider-rail"></div>
<div class="slider-track" :style="trackStyle"></div>
<div
v-if="range"
tabindex="0"
ref="lowHandle"
class="slider-handle"
:style="lowHandleStyle"
@keydown.left.prevent="disabled ? () => false : onLowSlide(low, 'low')"
@keydown.right.prevent="disabled ? () => false : onHighSlide(low, 'low')"
@keydown.down.prevent="disabled ? () => false : onLowSlide(low, 'low')"
@keydown.up.prevent="disabled ? () => false : onHighSlide(low, 'low')"
@mousedown="disabled ? () => false : onLowMouseDown()"
@blur="tooltip && !disabled ? handlerBlur(lowTooltip) : () => false"
>
<div v-if="tooltip" ref="lowTooltip" class="handle-tooltip">
{{ lowTooltipValue }}
<div class="tooltip-arrow"></div>
</div>
</div>
<div
tabindex="0"
ref="highHandle"
class="slider-handle"
:style="highHandleStyle"
@keydown.left.prevent="disabled ? () => false : onLowSlide(high, 'high')"
@keydown.right.prevent="disabled ? () => false : onHighSlide(high, 'high')"
@keydown.down.prevent="disabled ? () => false : onLowSlide(high, 'high')"
@keydown.up.prevent="disabled ? () => false : onHighSlide(high, 'high')"
@mousedown="disabled ? () => false : onHighMouseDown()"
@blur="tooltip && !disabled ? handlerBlur(highTooltip) : () => false"
>
<div v-if="tooltip" ref="highTooltip" class="handle-tooltip">
{{ highTooltipValue }}
<div class="tooltip-arrow"></div>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.m-slider {
position: relative;
cursor: pointer;
touch-action: none; // 禁用元素上的所有手势,使用自己的拖动和缩放api
.slider-rail {
// 灰色未选择滑动条背景色
position: absolute;
background-color: rgba(0, 0, 0, 0.04);
border-radius: 2px;
transition: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.slider-track {
// 蓝色已选择滑动条背景色
position: absolute;
background-color: #91caff;
border-radius: 2px;
transition: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
&:hover {
.slider-rail {
// 灰色未选择滑动条背景色
background: rgba(0, 0, 0, 0.1);
}
.slider-track {
// 蓝色已选择滑动条背景色
background: @themeColor;
}
}
.slider-handle {
// 滑块
position: absolute;
width: 10px;
height: 10px;
background: #fff;
box-shadow: 0 0 0 2px #91caff;
border-radius: 50%;
outline: none;
transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1),
height 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&::before {
content: '';
position: absolute;
left: -2px;
top: -2px;
width: 14px;
height: 14px;
background-color: transparent;
}
.hover-focus-handle {
width: 12px;
height: 12px;
box-shadow: 0 0 0 4px @themeColor;
}
&:hover,
&:focus {
.hover-focus-handle();
&::before {
left: -5px;
top: -5px;
width: 20px;
height: 20px;
}
}
.handle-tooltip {
position: relative;
display: inline-block;
width: max-content;
min-width: 32px;
max-width: 250px;
padding: 6px 8px;
height: 32px;
font-size: 14px;
color: #fff;
line-height: 20px;
text-align: center;
border-radius: 6px;
background: rgba(0, 0, 0, 0.85);
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
pointer-events: none;
user-select: none;
outline: none;
opacity: 0;
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
.tooltip-arrow {
position: absolute;
z-index: 9;
display: block;
pointer-events: none;
width: 16px;
height: 16px;
overflow: hidden;
&::before {
position: absolute;
bottom: 0;
left: 0;
width: 16px;
height: 8px;
background-color: rgba(0, 0, 0, 0.85);
clip-path: path('M 0 8 A 4 4 0 0 0 2.82842712474619 6.82842712474619 L 6.585786437626905 3.0710678118654755 A 2 2 0 0 1 9.414213562373096 3.0710678118654755 L 13.17157287525381 6.82842712474619 A 4 4 0 0 0 16 8 Z');
content: '';
}
&::after {
position: absolute;
width: 8.970562748477143px;
height: 8.970562748477143px;
bottom: 0;
inset-inline: 0;
margin: auto;
border-radius: 0 0 2px 0;
transform: translateY(50%) rotate(-135deg);
box-shadow: 3px 3px 7px rgba(0, 0, 0, 0.1);
z-index: 0;
background: transparent;
content: '';
}
}
}
}
}
.slider-horizontal {
padding-block: 4px;
height: 12px;
margin: 10px 5px;
.slider-rail {
height: 4px;
width: 100%;
}
.slider-track {
height: 4px;
}
.slider-handle {
top: 50%;
.handle-tooltip {
top: -32px;
left: 50%;
transform: translate(-50%, -50%) scale(0.8);
.tooltip-arrow {
left: 50%;
bottom: 0;
transform: translateX(-50%) translateY(100%) rotate(180deg);
}
}
.show-handle-tooltip {
pointer-events: auto;
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
&:hover {
.handle-tooltip {
.show-handle-tooltip();
}
}
}
}
.slider-vertical {
padding-inline: 4px;
width: 12px;
margin: 5px 10px;
.slider-rail {
width: 4px;
height: 100%;
}
.slider-track {
width: 4px;
}
.slider-handle {
left: 50%;
.handle-tooltip {
left: 100%;
top: 50%;
transform: translate(16px, -50%) scale(0.8);
.tooltip-arrow {
top: 50%;
left: 0;
transform: translateY(-50%) translateX(-100%) rotate(-90deg);
}
}
.show-handle-tooltip {
pointer-events: auto;
transform: translate(16px, -50%) scale(1);
opacity: 1;
}
&:hover {
.handle-tooltip {
.show-handle-tooltip();
}
}
}
}
.slider-disabled {
cursor: not-allowed;
.slider-rail {
background: rgba(0, 0, 0, 0.06);
}
.slider-track {
background: rgba(0, 0, 0, 0.25);
}
.slider-handle {
box-shadow: 0 0 0 2px #bfbfbf;
&:hover,
&:focus {
width: 10px;
height: 10px;
box-shadow: 0 0 0 2px #bfbfbf;
}
}
&:hover {
.slider-rail {
background: rgba(0, 0, 0, 0.06);
}
.slider-track {
background: rgba(0, 0, 0, 0.25);
}
}
}
</style>

View File

@@ -1,79 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
width?: string | number // 区域总宽度,单位 px
align?: 'stretch' | 'start' | 'end' | 'center' | 'baseline' // 垂直排列方式
vertical?: boolean // 是否为垂直布局
gap?: number | number[] | 'small' | 'middle' | 'large' // 间距大小,数组时表示: [水平间距, 垂直间距]
wrap?: boolean // 是否自动换行,仅在 horizontal 时有效
}
const props = withDefaults(defineProps<Props>(), {
width: 'auto',
align: 'start',
vertical: false,
gap: 'middle',
wrap: true
});
const spaceWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`;
}
return props.width;
});
const gapValue = computed(() => {
if (typeof props.gap === 'number') {
return `${props.gap}px`;
}
if (Array.isArray(props.gap)) {
return `${props.gap[1]}px ${props.gap[0]}px`;
}
if (['small', 'middle', 'large'].includes(props.gap)) {
const gapMap = {
small: '8px',
middle: '16px',
large: '24px'
};
return gapMap[props.gap];
}
return {};
});
</script>
<template>
<div
class="m-space"
:class="[`space-${align}`, { 'space-vertical': vertical, 'space-wrap': wrap }]"
:style="`width: ${spaceWidth}; gap: ${gapValue}; margin-bottom: -${Array.isArray(props.gap) && wrap ? props.gap[1] : 0}px;`"
>
<slot></slot>
</div>
</template>
<style lang="less" scoped>
.m-space {
display: inline-flex;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
transition: all 0.3s;
flex-direction: row;
}
.space-vertical {
flex-direction: column;
}
.space-stretch {
align-items: stretch;
}
.space-start {
align-items: flex-start;
}
.space-end {
align-items: flex-end;
}
.space-center {
align-items: center;
}
.space-baseline {
align-items: baseline;
}
.space-wrap {
flex-wrap: wrap;
}
</style>

View File

@@ -1,99 +0,0 @@
<script setup lang="ts">
import type {CSSProperties} from 'vue';
import {computed} from 'vue';
import {formatNumber, useSlotsExist} from '../Utils';
interface Props {
title?: string // 数值的标题 string | slot
value?: string | number // 数值的内容 string | number | slot
valueStyle?: CSSProperties // 设置数值的样式
precision?: number // 数值精度
prefix?: string // 设置数值的前缀 string | slot
suffix?: string // 设置数值的后缀 string | slot
separator?: string // 设置千分位标识符
formatter?: (value: string) => string // 自定义数值展示
}
const props = withDefaults(defineProps<Props>(), {
title: undefined,
value: undefined,
valueStyle: () => ({}),
precision: 0,
prefix: undefined,
suffix: undefined,
separator: ',',
formatter: (value: string) => value
});
const slotsExist = useSlotsExist(['title', 'prefix', 'suffix']);
const showValue = computed(() => {
return props.formatter(formatNumber(props.value || '', props.precision, props.separator));
});
const showTitle = computed(() => {
return slotsExist.title || props.title;
});
const showPrefix = computed(() => {
return slotsExist.prefix || props.prefix;
});
const showSuffix = computed(() => {
return slotsExist.suffix || props.suffix;
});
</script>
<template>
<div class="m-statistic">
<div v-if="showTitle" class="statistic-title">
<slot name="title">{{ title }}</slot>
</div>
<div class="statistic-content" :style="valueStyle">
<span v-if="showPrefix" class="statistic-prefix">
<slot name="prefix">{{ prefix }}</slot>
</span>
<span class="statistic-value">
<slot>{{ showValue }}</slot>
</span>
<span v-if="showSuffix" class="statistic-suffix">
<slot name="suffix">{{ suffix }}</slot>
</span>
</div>
</div>
</template>
<style lang="less" scoped>
.m-statistic {
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
.statistic-title {
margin-bottom: 4px;
color: rgba(0, 0, 0, 0.45);
font-size: 14px;
}
.statistic-content {
color: rgba(0, 0, 0, 0.88);
font-size: 24px;
.statistic-prefix {
display: inline-block;
margin-right: 4px;
:deep(svg) {
fill: currentColor;
}
}
.statistic-value {
display: inline-block;
direction: ltr;
}
.statistic-suffix {
display: inline-block;
margin-left: 4px;
:deep(svg) {
fill: currentColor;
}
}
}
}
</style>

View File

@@ -1,617 +0,0 @@
<script setup lang="ts">
import {computed} from 'vue';
interface Step {
title?: string // 标题
description?: string // 描述
}
interface Props {
steps?: Step[] // 步骤数组
width?: number | string // 步骤条总宽度,单位 px
size?: 'default' | 'small' // 步骤条大小
vertical?: boolean // 是否使用垂直步骤条,当 vertical: true 时labelPlacement 自动设为 right
labelPlacement?: 'right' | 'bottom' // 标签放置位置,默认放图标右侧,可选 bottom 放图标下方
dotted?: boolean // 是否使用点状步骤条,当 dotted: true 且 vertical: false 时labelPlacement 将自动设为 bottom
current?: number // (v-model) 当前选中的步骤,设置 v-model 后Steps 变为可点击状态。从 1 开始计数
}
const props = withDefaults(defineProps<Props>(), {
steps: () => [],
width: 'auto',
size: 'default',
vertical: false,
labelPlacement: 'right',
dotted: false,
current: 1
});
const emits = defineEmits(['update:current', 'change']);
const totalWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`;
} else {
return props.width;
}
});
const totalSteps = computed(() => {
// 步骤总数
return props.steps.length;
});
const currentStep = computed(() => {
if (props.current < 1) {
return 1;
} else if (props.current > totalSteps.value + 1) {
return totalSteps.value + 1;
} else {
return props.current;
}
});
function onChange(index: number) {
// 点击切换选择步骤
if (currentStep.value !== index) {
emits('update:current', index);
emits('change', index);
}
}
</script>
<template>
<div
class="m-steps"
:class="{
'steps-small': size === 'small',
'steps-vertical': vertical,
'steps-label-bottom': !vertical && (labelPlacement === 'bottom' || dotted),
'steps-dotted': dotted
}"
:style="`width: ${totalWidth};`"
>
<div
class="m-steps-item"
:class="{
'steps-finish': currentStep > index + 1,
'steps-process': currentStep === index + 1,
'steps-wait': currentStep < index + 1
}"
v-for="(step, index) in steps"
:key="index"
>
<div tabindex="0" class="steps-info-wrap" @click="onChange(index + 1)">
<div class="steps-tail"></div>
<div class="steps-icon">
<template v-if="!dotted">
<span v-if="currentStep <= index + 1" class="steps-num">{{ index + 1 }}</span>
<svg
v-else
class="icon-svg"
focusable="false"
data-icon="check"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 00-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z"
></path>
</svg>
</template>
<template v-else>
<span class="steps-dot"></span>
</template>
</div>
<div class="m-steps-content">
<div class="steps-title">{{ step.title }}</div>
<div v-if="step.description" class="steps-description">{{ step.description }}</div>
</div>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.m-steps {
display: flex;
gap: 16px;
transition: all 0.3s;
&:not(.steps-label-bottom) {
.m-steps-item .steps-info-wrap {
.steps-tail {
display: none;
}
}
}
.m-steps-item {
position: relative;
overflow: hidden;
flex: 1; // 弹性盒模型对象的子元素都有相同的长度,且忽略它们内部的内容
vertical-align: top;
&:last-child {
flex: none;
.steps-info-wrap {
.m-steps-content .steps-title {
padding-right: 0;
&::after {
display: none;
}
}
.steps-tail {
display: none;
}
}
}
.steps-info-wrap {
display: inline-block;
vertical-align: top;
outline: none;
.steps-tail {
height: 9px;
position: absolute;
top: 12px;
left: 0;
width: 100%;
transition: all 0.3s;
&::after {
display: inline-block;
vertical-align: top;
width: 100%;
height: 1px;
background-color: rgba(5, 5, 5, 0.06);
border-radius: 1px;
transition: background-color 0.3s;
content: '';
}
}
.steps-icon {
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 8px;
width: 32px;
height: 32px;
border-radius: 50%;
text-align: center;
background-color: rgba(0, 0, 0, 0.06);
border: 1px solid transparent;
transition: all 0.3s;
.steps-num {
display: inline-block;
font-size: 16px;
line-height: 1;
color: rgba(0, 0, 0, 0.65);
transition: all 0.3s;
}
.icon-svg {
display: inline-block;
font-size: 16px;
color: @themeColor;
fill: currentColor;
transition: all 0.3s;
}
.steps-dot {
width: 100%;
height: 100%;
border-radius: 50%;
transition: all 0.3s;
}
}
.m-steps-content {
display: inline-block;
vertical-align: top;
transition: all 0.3s;
.steps-title {
position: relative;
display: inline-block;
color: rgba(0, 0, 0, 0.45);
font-size: 16px;
line-height: 32px;
transition: all 0.3s;
padding-right: 16px;
&::after {
background: #e8e8e8;
position: absolute;
top: 16px;
left: 100%;
display: block;
width: 3000px;
height: 1px;
content: '';
cursor: auto;
transition: all 0.3s;
}
}
.steps-description {
max-width: 140px;
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
line-height: 22px;
word-break: break-all;
transition: all 0.3s;
}
}
}
}
.steps-finish {
.steps-info-wrap {
cursor: pointer;
.steps-tail {
&::after {
background-color: @themeColor;
}
}
.steps-icon {
background-color: #e6f4ff;
border-color: #e6f4ff;
.steps-dot {
background: @themeColor;
}
}
.m-steps-content {
.steps-title {
color: rgba(0, 0, 0, 0.88);
&::after {
background-color: @themeColor;
}
}
.steps-description {
color: rgba(0, 0, 0, 0.45);
}
}
&:hover {
.steps-icon {
border-color: @themeColor;
}
.m-steps-content {
.steps-title,
.steps-description {
color: @themeColor;
}
}
}
}
}
.steps-process {
.steps-info-wrap {
.steps-icon {
background-color: @themeColor;
border: 1px solid rgba(0, 0, 0, 0.25);
border-color: @themeColor;
.steps-num {
color: #fff;
}
.steps-dot {
background: @themeColor;
}
}
.m-steps-content {
.steps-title,
.steps-description {
color: rgba(0, 0, 0, 0.88);
}
}
}
}
.steps-wait {
.steps-info-wrap {
cursor: pointer;
&:hover {
.steps-icon {
border-color: @themeColor;
.steps-num {
color: @themeColor;
}
}
.m-steps-content {
.steps-title,
.steps-description {
color: @themeColor;
}
}
}
.steps-icon {
.steps-dot {
background: rgba(0, 0, 0, 0.25);
}
}
}
}
}
.steps-small {
gap: 12px;
.m-steps-item {
.steps-info-wrap {
.steps-icon {
width: 24px;
height: 24px;
.steps-num,
.icon-svg {
font-size: 12px;
}
}
.m-steps-content {
.steps-title {
font-size: 14px;
line-height: 24px;
padding-right: 12px;
&::after {
top: 12px;
}
}
}
}
}
}
.steps-label-bottom {
gap: 0;
.m-steps-item {
overflow: visible;
.steps-info-wrap {
.steps-tail {
margin-left: 56px;
padding: 4px 24px;
}
.steps-icon {
margin-left: 40px;
}
.m-steps-content {
display: block;
width: 112px;
margin-top: 12px;
text-align: center;
.steps-title {
padding-right: 0;
&::after {
display: none;
}
}
}
}
}
}
.steps-dotted {
.m-steps-item {
overflow: visible;
.steps-info-wrap {
.steps-tail {
height: 3px;
top: 2.5px;
width: 100%;
margin-top: 0;
margin-bottom: 0;
margin-inline: 70px 0;
padding: 0;
&::after {
width: calc(100% - 24px);
height: 3px;
margin-left: 12px;
}
}
.steps-icon {
position: absolute;
width: 8px;
height: 8px;
margin-left: 66px;
padding-right: 0;
line-height: 8px;
background: transparent;
border: 0;
vertical-align: top;
}
.m-steps-content {
width: 140px;
margin-top: 20px;
.steps-title {
line-height: 1.5714285714285714;
}
}
}
}
.steps-process {
.steps-info-wrap .steps-icon {
top: -1px;
width: 10px;
height: 10px;
line-height: 10px;
margin-left: 65px;
}
}
}
.steps-vertical {
display: inline-flex;
flex-direction: column;
gap: 0;
.m-steps-item {
flex: 1 0 auto;
overflow: visible;
&:last-child {
flex: 1 0 auto;
}
&:not(:last-child) {
.steps-info-wrap {
.steps-tail {
display: block;
}
.m-steps-content {
.steps-title {
&::after {
display: none;
}
}
}
}
}
.steps-info-wrap {
.steps-tail {
top: 0;
left: 15px;
width: 1px;
height: 100%;
padding: 38px 0 6px;
&::after {
width: 1px;
height: 100%;
}
}
.steps-icon {
float: left;
margin-right: 16px;
}
.m-steps-content {
display: block;
min-height: 48px;
overflow: hidden;
.steps-title {
line-height: 32px;
}
.steps-description {
padding-bottom: 12px;
}
}
}
}
}
.steps-small.steps-vertical {
.m-steps-item {
.steps-info-wrap {
.steps-tail {
left: 11px;
padding: 30px 0 6px;
}
.m-steps-content {
.steps-title {
line-height: 24px;
}
}
}
}
}
.steps-vertical.steps-dotted {
.m-steps-item {
.steps-info-wrap {
.steps-tail {
top: 12px;
left: 0;
margin: 0;
padding: 16px 0 8px;
&::after {
margin-left: 3.5px;
}
}
.steps-icon {
position: static;
margin-top: 12px;
margin-left: 0;
background: none;
}
.m-steps-content {
width: inherit;
margin: 0;
}
}
}
.steps-process {
.steps-info-wrap {
.steps-icon {
position: relative;
margin-top: 11px;
top: 0;
left: -1px;
}
}
}
}
.steps-small.steps-vertical.steps-dotted {
.m-steps-item {
.steps-info-wrap {
.steps-tail {
top: 8px;
}
.steps-icon {
margin-top: 8px;
}
}
}
.steps-process {
.steps-info-wrap {
.steps-icon {
margin-top: 7px;
}
}
}
}
</style>

View File

@@ -1,223 +0,0 @@
<script setup lang="ts">
import {computed, ref} from 'vue';
import type {Swiper as SwiperTypes} from 'swiper/types';
import {Swiper, SwiperSlide} from 'swiper/vue';
import {
Autoplay,
EffectCards,
EffectCoverflow,
EffectCreative,
EffectCube,
EffectFade,
EffectFlip,
Mousewheel,
Navigation,
Pagination
} from 'swiper/modules';
import 'swiper/less';
import 'swiper/less/navigation';
import 'swiper/less/pagination';
import 'swiper/less/effect-fade';
import 'swiper/less/effect-cube';
import 'swiper/less/effect-flip';
import 'swiper/less/effect-coverflow';
import 'swiper/less/effect-cards';
import 'swiper/less/effect-creative';
interface Image {
name?: string // 图片名称
src: string // 图片地址
link?: string // 图片跳转链接
}
interface Props {
images?: Image[] // 轮播图片数组
width?: number | string // 轮播区域宽度,单位 px
height?: number | string // 轮播区域高度,单位 px
mode?: 'banner' | 'carousel' | 'broadcast' // banner: 轮播图模式; carousel: 走马灯模式; broadcast: 信息展播模式
navigation?: boolean // 是否显示导航
effect?: 'slide' | 'fade' | 'cube' | 'flip' | 'coverflow' | 'cards' | 'creative' // 切换动画效果
delay?: number // 自动切换的时间间隔,仅当 mode: 'banner' 时生效,单位 ms
speed?: number // 切换过渡的动画持续时间,单位 ms
loop?: boolean // 是否循环切换
pauseOnMouseEnter?: boolean // 当鼠标移入走马灯时,是否暂停自动轮播,仅当 mode: 'banner' 或 mode: 'carousel' 时生效
swipe?: boolean // 是否可以鼠标拖动
preloaderColor?: 'theme' | 'white' | 'black' // 预加载时的 loading 颜色
}
const props = withDefaults(defineProps<Props>(), {
images: () => [],
width: '100%',
height: '100%',
mode: 'banner',
navigation: false,
effect: 'slide',
delay: 3000,
speed: 300,
loop: true,
pauseOnMouseEnter: false,
swipe: true,
preloaderColor: 'theme'
});
const autoplayBanner = ref({
delay: props.delay,
disableOnInteraction: false, // 用户操作 swiper 之后,是否禁止 autoplay。默认为 true停止。
pauseOnMouseEnter: props.pauseOnMouseEnter // 鼠标置于 swiper 时暂停自动切换,鼠标离开时恢复自动切换,默认 false
});
const modulesCarousel = ref([Autoplay]);
const autoplayCarousel = ref<object | boolean>({
delay: 0,
disableOnInteraction: false
});
const modulesBroadcast = ref([Navigation, Pagination, Mousewheel]);
const emits = defineEmits(['swiper', 'change']);
const swiperWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`;
} else {
return props.width;
}
});
const swiperHeight = computed(() => {
if (typeof props.height === 'number') {
return `${props.height}px`;
} else {
return props.height;
}
});
const modulesBanner = computed(() => {
const modules = [Navigation, Pagination, Autoplay];
const effectMoudles = {
fade: EffectFade,
cube: EffectCube,
flip: EffectFlip,
coverflow: EffectCoverflow,
cards: EffectCards,
creative: EffectCreative
};
if (props.effect !== 'slide') {
modules.push(effectMoudles[props.effect]);
}
return modules;
});
function onSwiper(swiper: SwiperTypes) {
emits('swiper', swiper);
if (props.mode === 'carousel' && props.pauseOnMouseEnter) {
swiper.el.onmouseenter = () => {
// 移入暂停
swiper.autoplay.stop();
};
swiper.el.onmouseleave = () => {
// 移出启动
swiper.autoplay.start();
};
}
}
function getImageName(image: Image) {
// 从图片地址 src 中获取图片名称
if (image.name) {
return image.name;
} else {
const res = image.src.split('?')[0].split('/');
return res[res.length - 1];
}
}
</script>
<template>
<Swiper
v-if="mode === 'banner'"
:class="{ 'swiper-no-swiping': !swipe }"
:style="`width: ${swiperWidth}; height: ${swiperHeight};`"
:modules="modulesBanner"
:navigation="navigation"
:slides-per-view="1"
:autoplay="autoplayBanner"
:effect="effect"
:speed="speed"
:loop="loop"
lazy
@swiper="onSwiper"
@slideChange="(swiper) => $emit('change', swiper)"
v-bind="$attrs"
>
<SwiperSlide v-for="(image, index) in images" :key="index">
<component :is="image.link ? 'a' : 'div'" class="swiper-link" :href="image.link" target="_blank">
<img class="swiper-image" :src="image.src" :alt="getImageName(image)" loading="lazy"/>
</component>
<div :class="`swiper-lazy-preloader swiper-lazy-preloader-${preloaderColor}`"></div>
</SwiperSlide>
</Swiper>
<Swiper
v-if="mode === 'carousel'"
class="swiper-no-swiping"
:style="`width: ${swiperWidth}; height: ${swiperHeight};`"
:modules="modulesCarousel"
:autoplay="autoplayCarousel"
:speed="speed"
:loop="loop"
lazy
@swiper="onSwiper"
@slideChange="(swiper) => $emit('change', swiper)"
v-bind="$attrs"
>
<SwiperSlide v-for="(image, index) in images" :key="index">
<component :is="image.link ? 'a' : 'div'" class="swiper-link" :href="image.link" target="_blank">
<img class="swiper-image" :src="image.src" :alt="getImageName(image)" loading="lazy"/>
</component>
<div :class="`swiper-lazy-preloader swiper-lazy-preloader-${preloaderColor}`"></div>
</SwiperSlide>
</Swiper>
<Swiper
v-if="mode === 'broadcast'"
:style="`width: ${swiperWidth}; height: ${swiperHeight};`"
:modules="modulesBroadcast"
:navigation="navigation"
:speed="speed"
:loop="loop"
lazy
@swiper="onSwiper"
@slideChange="(swiper) => $emit('change', swiper)"
v-bind="$attrs"
>
<SwiperSlide v-for="(image, index) in images" :key="index">
<component :is="image.link ? 'a' : 'div'" class="swiper-link" :href="image.link" target="_blank">
<img class="swiper-image" :src="image.src" :alt="getImageName(image)" loading="lazy"/>
</component>
<div :class="`swiper-lazy-preloader swiper-lazy-preloader-${preloaderColor}`"></div>
</SwiperSlide>
</Swiper>
</template>
<style lang="less" scoped>
.swiper-link {
display: block;
height: 100%;
.swiper-image {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
}
}
.swiper {
--swiper-theme-color: @themeColor;
}
:deep(.swiper-wrapper) {
// 自动切换过渡效果设置
transition-timing-function: linear; // 线性过渡模拟走马灯效果
-webkit-transition-timing-function: linear;
}
:deep(.swiper-pagination-bullet) {
width: 12px;
height: 12px;
}
.swiper-lazy-preloader-theme {
--swiper-preloader-color: @themeColor;
}
</style>

View File

@@ -1,380 +0,0 @@
<script setup lang="ts">
import type {CSSProperties} from 'vue';
import {nextTick, ref} from 'vue';
interface Props {
checked?: string // 选中时的内容 string | slot
checkedValue?: boolean | string | number // 选中时的值
unchecked?: string // 未选中时的内容 string | slot
uncheckedValue?: boolean | string | number // 未选中时的值
loading?: boolean // 是否加载中
disabled?: boolean // 是否禁用
size?: 'small' | 'middle' | 'large' // 开关大小
rippleColor?: string // 点击时的波纹颜色,当自定义选中颜色时需要设置
circleStyle?: CSSProperties // 圆点样式
modelValue?: boolean | string | number // (v-model) 指定当前是否选中
}
const props = withDefaults(defineProps<Props>(), {
checked: undefined,
checkedValue: true,
unchecked: undefined,
uncheckedValue: false,
loading: false,
disabled: false,
size: 'middle',
rippleColor: '#1677ff',
circleStyle: () => ({}),
modelValue: false
});
const wave = ref(false);
const emit = defineEmits(['update:modelValue', 'change']);
function onSwitch() {
if (props.modelValue === props.checkedValue) {
emit('update:modelValue', props.uncheckedValue);
emit('change', props.uncheckedValue);
} else {
emit('update:modelValue', props.checkedValue);
emit('change', props.checkedValue);
}
if (wave.value) {
wave.value = false;
nextTick(() => {
wave.value = true;
});
} else {
wave.value = true;
}
}
function onWaveEnd() {
wave.value = false;
}
</script>
<template>
<div
class="m-switch"
:class="{
'switch-loading': loading,
'switch-small': size === 'small',
'switch-large': size === 'large',
'switch-checked': modelValue === checkedValue,
'switch-disabled': disabled
}"
:style="`--ripple-color: ${rippleColor};`"
@click="disabled || loading ? () => false : onSwitch()"
>
<div class="switch-inner">
<span class="inner-checked">
<slot name="checked">{{ checked }}</slot>
</span>
<span class="inner-unchecked">
<slot name="unchecked">{{ unchecked }}</slot>
</span>
</div>
<div class="switch-circle" :style="circleStyle">
<svg v-if="loading" class="circular" viewBox="0 0 50 50">
<circle class="path" cx="25" cy="25" r="20" fill="none"></circle>
</svg>
<slot name="node" :checked="modelValue"></slot>
</div>
<div v-if="!disabled" class="switch-wave" :class="{ 'wave-active': wave }" @animationend="onWaveEnd"></div>
</div>
</template>
<style lang="less" scoped>
.m-switch {
position: relative;
display: inline-flex;
align-items: center;
vertical-align: middle;
min-width: 44px;
height: 22px;
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
line-height: 22px;
background: rgba(0, 0, 0, 0.25);
border-radius: 100px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
&:hover:not(.switch-disabled) {
background: rgba(0, 0, 0, 0.45);
}
.switch-inner {
display: block;
overflow: hidden;
border-radius: 100px;
height: 100%;
padding-left: 24px;
padding-right: 9px;
transition: padding-left 0.2s ease-in-out,
padding-right 0.2s ease-in-out;
.inner-checked {
margin-left: calc(-100% + 22px - 48px);
margin-right: calc(100% - 22px + 48px);
display: block;
text-align: center;
color: #fff;
font-size: 14px;
transition: margin-left 0.2s ease-in-out,
margin-right 0.2s ease-in-out;
pointer-events: none;
}
.inner-unchecked {
margin-top: -22px;
margin-left: 0;
margin-right: 0;
display: block;
text-align: center;
color: #fff;
font-size: 14px;
transition: margin-left 0.2s ease-in-out,
margin-right 0.2s ease-in-out;
pointer-events: none;
}
}
.switch-circle {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background: #fff;
border-radius: 100%;
transition: all 0.2s ease-in-out;
.circular {
position: absolute;
inset: 0;
margin: auto;
width: 14px;
height: 14px;
animation: loading-rotate 2s linear infinite;
-webkit-animation: loading-rotate 2s linear infinite;
@keyframes loading-rotate {
100% {
transform: rotate(360deg);
}
}
.path {
stroke-dasharray: 90, 150;
stroke-dashoffset: 0;
stroke: @themeColor;
stroke-width: 5;
stroke-linecap: round;
animation: loading-dash 1.5s ease-in-out infinite;
-webkit-animation: loading-dash 1.5s ease-in-out infinite;
@keyframes loading-dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -40px;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -120px;
}
}
}
}
}
}
.switch-loading {
cursor: not-allowed;
opacity: 0.65;
.switch-inner,
.switch-circle {
box-shadow: none;
cursor: not-allowed;
}
}
.switch-small {
min-width: 28px;
height: 16px;
line-height: 16px;
.switch-inner {
padding-left: 18px;
padding-right: 6px;
.inner-checked {
font-size: 12px;
margin-left: calc(-100% + 16px - 36px);
margin-right: calc(100% - 16px + 36px);
}
.inner-unchecked {
font-size: 12px;
margin-top: -16px;
}
}
.switch-circle {
width: 12px;
height: 12px;
.circular {
width: 10px;
height: 10px;
.path {
stroke-width: 4;
}
}
}
}
.switch-large {
min-width: 60px;
height: 28px;
line-height: 28px;
.switch-inner {
padding-left: 30px;
padding-right: 12px;
.inner-checked {
font-size: 18px;
margin-left: calc(-100% + 28px - 60px);
margin-right: calc(100% - 28px + 60px);
}
.inner-unchecked {
font-size: 18px;
margin-top: -28px;
}
}
.switch-circle {
width: 24px;
height: 24px;
.circular {
width: 20px;
height: 20px;
.path {
stroke-width: 6;
}
}
}
}
.switch-checked {
background: @themeColor;
&:hover:not(.switch-disabled) {
background: #4096ff;
}
.switch-inner {
padding-left: 9px;
padding-right: 24px;
.inner-checked {
margin-left: 0;
margin-right: 0;
}
.inner-unchecked {
margin-left: calc(100% - 22px + 48px);
margin-right: calc(-100% + 22px - 48px);
}
}
.switch-circle {
left: calc(100% - 20px);
}
}
.switch-small.switch-checked {
.switch-inner {
padding-left: 6px;
padding-right: 18px;
.inner-unchecked {
margin-left: calc(100% - 16px + 36px);
margin-right: calc(-100% + 16px - 36px);
}
}
.switch-circle {
left: calc(100% - 14px);
}
}
.switch-large.switch-checked {
.switch-inner {
padding-left: 12px;
padding-right: 30px;
.inner-unchecked {
margin-left: calc(100% - 28px + 60px);
margin-right: calc(-100% + 28px - 60px);
}
}
.switch-circle {
left: calc(100% - 26px);
}
}
.switch-disabled {
cursor: not-allowed;
opacity: 0.65;
.switch-circle {
cursor: not-allowed;
}
}
.switch-wave {
position: absolute;
pointer-events: none;
top: 0;
right: 0;
bottom: 0;
left: 0;
animation-iteration-count: 1;
animation-duration: 0.6s;
animation-timing-function: cubic-bezier(0, 0, 0.2, 1), cubic-bezier(0, 0, 0.2, 1);
border-radius: inherit;
}
.wave-active {
z-index: 1;
animation-name: wave-spread, wave-opacity;
@keyframes wave-spread {
from {
box-shadow: 0 0 0.5px 0 var(--ripple-color);
}
to {
box-shadow: 0 0 0.5px 5px var(--ripple-color);
}
}
@keyframes wave-opacity {
from {
opacity: 0.6;
}
to {
opacity: 0;
}
}
}
</style>

View File

@@ -1,172 +0,0 @@
<script setup lang="ts">
import Spin from '../Spin/Spin.vue';
import Empty from '../Empty/Empty.vue';
import Pagination from '../Pagination/Pagination.vue';
interface Column {
title?: string // 列头显示文字
width?: number | string // 列宽度,单位 px
dataIndex: string // 列数据字符索引
slot?: string // 列插槽名称索引
}
interface Props {
columns?: Column[] // 表格列的配置项
dataSource?: any[] // 表格数据数组
loading?: boolean // 是否加载中
spinProps?: object // Spin 组件属性配置,参考 Spin Props用于配置数据加载中样式
emptyProps?: object // Empty 组件属性配置,参考 Empty Props用于配置暂无数据样式
showPagination?: boolean // 是否显示分页
pagination?: object // Pagination 组件属性配置,参考 Pagination Props用于配置分页功能
}
withDefaults(defineProps<Props>(), {
columns: () => [],
dataSource: () => [],
loading: false,
spinProps: () => ({}),
emptyProps: () => ({}),
showPagination: true,
pagination: () => ({})
});
const emit = defineEmits(['change']);
function onChange(page: number, pageSize: number) {
// 分页回调
emit('change', page, pageSize);
}
</script>
<template>
<div class="m-table-wrap">
<table class="m-table">
<thead>
<tr class="table-tr">
<th
class="table-th"
:style="`width: ${typeof item.width === 'number' ? item.width + 'px' : item.width};`"
v-for="(item, index) in columns"
:key="index"
>
{{ item.title }}
</th>
</tr>
</thead>
<tbody class="m-table-body">
<tr class="table-loading" v-show="loading">
<Spin class="loading" size="small" :colspan="columns.length" v-bind="spinProps"/>
</tr>
<tr class="table-empty-wrap" v-show="!dataSource.length">
<td class="table-empty" :colspan="columns.length">
<Empty class="empty" image="outlined" v-bind="emptyProps"/>
</td>
</tr>
<tr class="table-tr" v-for="(data, index) in dataSource" :key="index">
<td class="m-td" v-for="(col, n) in columns" :key="n" :title="data[col.dataIndex as any]">
<slot v-if="col.slot" v-bind="data" :name="col.slot" :index="index">{{
data[col.dataIndex as any] || '--'
}}
</slot>
<span v-else>{{ data[col.dataIndex as any] || '--' }}</span>
</td>
</tr>
</tbody>
</table>
<Pagination v-if="showPagination" class="mt16" @change="onChange" v-bind="pagination"/>
</div>
</template>
<style lang="less" scoped>
.m-table-wrap {
color: rgba(0, 0, 0, 0.65);
font-size: 14px;
line-height: 1.5714285714285714;
border-radius: 8px 8px 0 0;
.m-table {
display: table;
table-layout: fixed;
width: 100%;
text-align: left;
border-radius: 8px 8px 0 0;
border-collapse: separate;
border-spacing: 0;
margin: 0;
.table-th {
padding: 16px;
color: rgba(0, 0, 0, 0.85);
font-weight: 500;
text-align: left;
background: #fafafa;
border: none;
border-bottom: 1px solid #f0f0f0;
transition: background 0.3s ease;
&:first-child {
border-top-left-radius: 8px;
}
&:last-child {
border-top-right-radius: 8px;
}
}
.m-table-body {
position: relative;
.table-loading {
border: none;
background-color: #fff;
.loading {
position: absolute;
width: 100%;
height: 100%;
}
}
.table-empty-wrap {
border: none;
background-color: #fff;
&:hover {
background: #fff;
}
.table-empty {
padding: 16px;
border: none;
border-bottom: 1px solid #f0f0f0;
.empty {
margin: 32px 0;
}
}
}
}
.table-tr {
border: none;
background-color: #fff;
transition: background-color 0.3s;
.m-td {
padding: 16px;
border: none;
border-bottom: 1px solid #f0f0f0;
transition: background 0.3s;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&:hover {
background-color: rgba(0, 0, 0, 0.02);
}
}
}
.mt16 {
margin-top: 16px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,620 +0,0 @@
<script setup lang="ts">
import {computed, nextTick, ref, watchEffect} from 'vue';
import Space from '../Space/Space.vue';
import {useSlotsExist} from '../Utils';
interface Tag {
label?: string // 标签文本名 string | slot
closable?: boolean // 标签是否可以关闭,默认 true
color?: string // 标签颜色
icon?: string // 设置图标 string | slot
size?: 'small' | 'middle' | 'large' // 标签尺寸
bordered?: boolean // 是否有边框,默认 true
}
interface Props {
closable?: boolean // 标签是否可以关闭
color?: string // 标签颜色
icon?: string // 设置图标 string | slot
size?: 'small' | 'middle' | 'large' // 标签尺寸
bordered?: boolean // 是否有边框
dynamic?: boolean // 是否启用标签动态添加和删除
spaceProps?: object // Space 组件属性配置,仅当 dynamic: true 时生效
value?: string[] | Tag[] // 动态标签数组,仅当 dynamic: true 时生效
}
const props = withDefaults(defineProps<Props>(), {
closable: false,
color: undefined,
icon: undefined,
size: 'middle',
bordered: true,
dynamic: false,
spaceProps: () => ({}),
value: () => []
});
const inputRef = ref();
const showInput = ref(false);
const inputValue = ref('');
const presetColor = [
'success',
'processing',
'error',
'warning',
'default',
'pink',
'red',
'yellow',
'orange',
'cyan',
'green',
'blue',
'purple',
'geekblue',
'magenta',
'volcano',
'gold',
'lime'
];
const hidden = ref(false);
const tagsIconRef = ref();
const showTagsIcon = ref(Array(props.value.length).fill(1));
const slotsExist = useSlotsExist(['icon']);
const emits = defineEmits(['update:value', 'close', 'dynamicClose']);
const isStrArray = computed(() => {
if (props.dynamic) {
if (props.value.length) {
if (typeof props.value[0] === 'string') {
return true;
}
if (typeof props.value[0] === 'object') {
return false;
}
}
}
return null;
});
const tags = computed(() => {
if (props.dynamic) {
if (props.value.length) {
if (isStrArray.value) {
return props.value.map((tag: any) => {
return {
label: tag,
closable: true
};
});
} else {
return props.value.map((tag: any) => {
return {
closable: true,
...tag
};
});
}
}
}
return [];
});
const showIcon = computed(() => {
if (!props.dynamic) {
return slotsExist.icon || props.icon;
}
return false;
});
watchEffect(() => {
if (props.dynamic) {
const len = props.value.length;
showTagsIcon.value = Array(len).fill(1);
nextTick(() => {
if (tagsIconRef.value) {
for (let n = 0; n < len; n++) {
showTagsIcon.value[n] = tagsIconRef.value[n].offsetWidth;
}
}
});
}
});
function onClose(e: MouseEvent) {
hidden.value = true;
emits('close', e);
}
function onCloseTags(tag: Tag, n: number) {
const newValue = (props.value as any[]).filter((_tag: any, index: number) => {
return index !== n;
});
emits('update:value', newValue);
emits('dynamicClose', tag, n);
}
async function onAdd() {
showInput.value = true;
await nextTick();
inputRef.value.focus();
}
function onChange() {
if (isStrArray.value) {
emits('update:value', [...props.value, inputValue.value]);
} else {
emits('update:value', [
...props.value,
{
label: inputValue.value
}
]);
}
showInput.value = false;
inputRef.value = '';
}
function onKeyboard(e: KeyboardEvent) {
if (e.key === 'Enter') {
inputRef.value.blur();
}
}
</script>
<template>
<div
v-if="!dynamic"
class="m-tag"
:class="[
`tag-${size}`,
color && presetColor.includes(color) ? `tag-${color}` : '',
{
'tag-borderless': !bordered,
'tag-has-color': color && !presetColor.includes(color),
'tag-hidden': hidden
}
]"
:style="`background-color: ${color && !presetColor.includes(color) ? color : ''};`"
>
<span v-if="showIcon" class="tag-icon">
<slot name="icon">{{ icon }}</slot>
</span>
<span class="tag-content">
<slot></slot>
</span>
<span v-if="closable" class="tag-close" @click="onClose">
<svg
focusable="false"
class="close-svg"
data-icon="close"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M563.8 512l262.5-312.9c4.4-5.2.7-13.1-6.1-13.1h-79.8c-4.7 0-9.2 2.1-12.3 5.7L511.6 449.8 295.1 191.7c-3-3.6-7.5-5.7-12.3-5.7H203c-6.8 0-10.5 7.9-6.1 13.1L459.4 512 196.9 824.9A7.95 7.95 0 00203 838h79.8c4.7 0 9.2-2.1 12.3-5.7l216.5-258.1 216.5 258.1c3 3.6 7.5 5.7 12.3 5.7h79.8c6.8 0 10.5-7.9 6.1-13.1L563.8 512z"
></path>
</svg>
</span>
</div>
<Space v-else gap="small" v-bind="spaceProps">
<div
class="m-tag"
:class="[
`tag-${tag.size || size}`,
(tag.color || color) && presetColor.includes(tag.color || color) ? `tag-${tag.color || color}` : '',
{
'tag-borderless': tag.bordered !== undefined && !tag.bordered,
'tag-has-color': (tag.color || color) && !presetColor.includes(tag.color || color)
}
]"
:style="`background-color: ${(tag.color || color) && !presetColor.includes(tag.color || color) ? tag.color || color : ''};`"
v-for="(tag, index) in tags"
:key="index"
>
<span v-if="showTagsIcon[index]" ref="tagsIconRef" class="tag-icon">
<slot name="icon" :index="index">{{ tag.icon }}</slot>
</span>
<span class="tag-content">
<slot :label="tag.label" :index="index">{{ tag.label }}</slot>
</span>
<span v-if="tag.closable || closable" class="tag-close" @click="onCloseTags(tag, index)">
<svg
focusable="false"
class="close-svg"
data-icon="close"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M563.8 512l262.5-312.9c4.4-5.2.7-13.1-6.1-13.1h-79.8c-4.7 0-9.2 2.1-12.3 5.7L511.6 449.8 295.1 191.7c-3-3.6-7.5-5.7-12.3-5.7H203c-6.8 0-10.5 7.9-6.1 13.1L459.4 512 196.9 824.9A7.95 7.95 0 00203 838h79.8c4.7 0 9.2-2.1 12.3-5.7l216.5-258.1 216.5 258.1c3 3.6 7.5 5.7 12.3 5.7h79.8c6.8 0 10.5-7.9 6.1-13.1L563.8 512z"
></path>
</svg>
</span>
</div>
<div v-if="!showInput" class="m-tag" :class="[`tag-${size}`, { 'tag-plus': dynamic }]" @click="onAdd">
<svg
focusable="false"
class="plus-svg"
data-icon="plus"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path d="M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z"></path>
<path d="M176 474h672q8 0 8 8v60q0 8-8 8H176q-8 0-8-8v-60q0-8 8-8z"></path>
</svg>
</div>
<input
v-if="showInput"
ref="inputRef"
class="tag-input"
:class="`input-${size}`"
type="text"
v-model="inputValue"
@blur="showInput = false"
@change="onChange"
@keydown="onKeyboard"
/>
</Space>
</template>
<style lang="less" scoped>
.m-tag {
display: inline-flex;
align-items: center;
height: 24px;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
padding-inline: 7px;
white-space: nowrap;
background: rgba(0, 0, 0, 0.02);
border: 1px solid #d9d9d9;
border-radius: 6px;
transition: all 0.2s;
text-align: start;
.tag-icon {
margin-right: 5px;
height: 100%;
display: inline-flex;
align-items: center;
:deep(svg) {
fill: currentColor;
}
}
.tag-content {
height: 100%;
display: inline-flex;
align-items: center;
}
.plus-svg {
display: inline-flex;
align-items: center;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
fill: currentColor;
font-style: normal;
line-height: 0;
text-align: center;
vertical-align: -0.175em;
transition: color 0.2s;
}
.tag-close {
margin-left: 3px;
display: inline-flex;
align-items: center;
height: 100%;
vertical-align: top;
font-style: normal;
line-height: 0;
text-align: center;
cursor: pointer;
.close-svg {
display: inline-block;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
fill: currentColor;
transition: all 0.2s;
&:hover {
color: rgba(0, 0, 0, 0.88);
}
}
}
}
.tag-small {
height: 22px;
font-size: 12px;
line-height: 20px;
border-radius: 4px;
.plus-svg {
font-size: 12px;
}
.tag-close {
font-size: 10px;
}
}
.tag-large {
height: 28px;
line-height: 26px;
.tag-close {
font-size: 14px;
vertical-align: -0.16em;
}
}
.tag-plus {
background: rgb(255, 255, 255);
border-style: dashed;
padding-inline: 10px;
text-align: center;
cursor: pointer;
&:hover {
border-color: @themeColor;
.plus-svg {
color: @themeColor;
}
}
}
.tag-input {
width: 86px;
color: rgba(0, 0, 0, 0.88);
height: 24px;
font-size: 14px;
line-height: 22px;
padding: 0 8px;
position: relative;
display: inline-block;
min-width: 0;
background-color: #ffffff;
border: 1px solid #d9d9d9;
border-radius: 6px;
outline: none;
transition: all 0.2s;
&:focus {
border-color: #4096ff;
box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
border-right-width: 1px;
outline: 0;
}
}
.input-small {
width: 78px;
height: 22px;
font-size: 12px;
line-height: 20px;
padding: 0 6px;
border-radius: 4px;
}
.input-large {
width: 90px;
height: 28px;
line-height: 26px;
}
.tag-success {
color: #52c41a;
background: #f6ffed;
border-color: #b7eb8f;
:deep(svg) {
color: #52c41a;
fill: currentColor;
}
}
.tag-processing {
color: @themeColor;
background: #e6f4ff;
border-color: #91caff;
:deep(svg) {
color: @themeColor;
fill: currentColor;
}
}
.tag-error {
color: #ff4d4f;
background: #fff2f0;
border-color: #ffccc7;
:deep(svg) {
color: #ff4d4f;
fill: currentColor;
}
}
.tag-warning {
color: #faad14;
background: #fffbe6;
border-color: #ffe58f;
:deep(svg) {
color: #faad14;
fill: currentColor;
}
}
.tag-pink {
color: #c41d7f;
background: #fff0f6;
border-color: #ffadd2;
:deep(svg) {
color: #c41d7f;
fill: currentColor;
}
}
.tag-red {
color: #cf1322;
background: #fff1f0;
border-color: #ffa39e;
:deep(svg) {
color: #cf1322;
fill: currentColor;
}
}
.tag-yellow {
color: #d4b106;
background: #feffe6;
border-color: #fffb8f;
:deep(svg) {
color: #d4b106;
fill: currentColor;
}
}
.tag-orange {
color: #d46b08;
background: #fff7e6;
border-color: #ffd591;
:deep(svg) {
color: #d46b08;
fill: currentColor;
}
}
.tag-green {
color: #389e0d;
background: #f6ffed;
border-color: #b7eb8f;
:deep(svg) {
color: #389e0d;
fill: currentColor;
}
}
.tag-cyan {
color: #08979c;
background: #e6fffb;
border-color: #87e8de;
:deep(svg) {
color: #08979c;
fill: currentColor;
}
}
.tag-blue {
color: #0958d9;
background: #e6f4ff;
border-color: #91caff;
:deep(svg) {
color: #0958d9;
fill: currentColor;
}
}
.tag-purple {
color: #531dab;
background: #f9f0ff;
border-color: #d3adf7;
:deep(svg) {
color: #531dab;
fill: currentColor;
}
}
.tag-geekblue {
color: #1d39c4;
background: #f0f5ff;
border-color: #adc6ff;
:deep(svg) {
color: #1d39c4;
fill: currentColor;
}
}
.tag-magenta {
color: #eb2f96;
background: #fff0f6;
border-color: #ffadd2;
:deep(svg) {
color: #eb2f96;
fill: currentColor;
}
}
.tag-volcano {
color: #d4380d;
background: #fff2e8;
border-color: #ffbb96;
:deep(svg) {
color: #d4380d;
fill: currentColor;
}
}
.tag-gold {
color: #d48806;
background: #fffbe6;
border-color: #ffe58f;
:deep(svg) {
color: #d48806;
fill: currentColor;
}
}
.tag-lime {
color: #7cb305;
background: #fcffe6;
border-color: #eaff8f;
:deep(svg) {
color: #7cb305;
fill: currentColor;
}
}
.tag-borderless {
border-color: transparent;
}
.tag-has-color {
color: #fff;
border-color: transparent;
.tag-close .close-svg {
color: rgba(255, 255, 255, 0.85);
&:hover {
color: rgba(255, 255, 255, 1);
}
}
}
.tag-hidden {
display: none;
}
</style>

View File

@@ -1,336 +0,0 @@
<script setup lang="ts">
import type {CSSProperties} from 'vue';
import {computed, ref, watch, watchEffect} from 'vue';
import {cancelRaf, rafTimeout, useResizeObserver} from '../Utils';
interface Text {
title: string // 文字标题
href?: string // 跳转链接
target?: '_self' | '_blank' // 跳转链接打开方式href 存在时生效
}
interface Props {
scrollText?: Text[] | Text // 滚动文字数组single 为 true 时,类型为 Text多条文字滚动时数组长度必须大于等于 amount 才能滚动
single?: boolean // 是否启用单条文字滚动效果,只支持水平文字滚动,为 true 时amount 自动设为 1
width?: number | string // 滚动区域宽度,单位 px
height?: number // 滚动区域高度,单位 px
boardStyle?: CSSProperties // 滚动区域样式,优先级低于 width、height
textStyle?: CSSProperties // 滚动文字样式
hrefHoverColor?: string // 链接文字鼠标悬浮颜色;仅当 href 存在时生效
amount?: number // 滚动区域展示条数,水平滚动时生效
gap?: number // 水平滚动文字各列间距或垂直滚动文字两边的边距,单位 px
interval?: number // 水平滚动动画执行时间间隔,单位 ms水平滚动时生效
step?: number // 水平滚动动画每次执行时移动距离,单位 px水平滚动时生效与 interval 配合控制滚动速度
vertical?: boolean // 是否垂直滚动
verticalInterval?: number // 垂直文字滚动时间间隔,单位 ms垂直滚动时生效
}
const props = withDefaults(defineProps<Props>(), {
scrollText: () => [],
single: false,
width: '100%',
height: 50,
boardStyle: () => ({}),
textStyle: () => ({}),
hrefHoverColor: '#1677ff',
amount: 4,
gap: 20,
interval: 10,
step: 1,
vertical: false,
verticalInterval: 3000
});
const horizontalRef = ref(); // 水平滚动 DOM 引用
const verticalRef = ref(); // 垂直滚动 DOM 引用
const left = ref(0); // 水平滚动偏移距离
const distance = ref(0); // 每条滚动文字移动距离
const activeIndex = ref(0); // 垂直滚动当前索引
const horizontalMoveRaf = ref();
const verticalMoveRaf = ref();
const origin = ref(true); // 垂直滚动初始状态
const emit = defineEmits(['click']);
const textData = ref<Text[]>([]);
const textAmount = computed(() => {
return textData.value.length;
});
const totalWidth = computed(() => {
// 文字滚动区域总宽度
if (typeof props.width === 'number') {
return `${props.width}px`;
} else {
return props.width;
}
});
const displayAmount = computed(() => {
if (props.single) {
return 1;
} else {
return props.amount;
}
});
watch(
() => [
textData.value,
props.width,
props.amount,
props.gap,
props.step,
props.interval,
props.vertical,
props.verticalInterval
],
() => {
initScroll();
},
{
deep: true, // 强制转成深层侦听器
flush: 'post'
}
);
watchEffect(() => {
initScrollText();
});
useResizeObserver([horizontalRef, verticalRef], () => {
initScroll();
});
function initScrollText() {
left.value = 0;
activeIndex.value = 0;
if (props.single) {
textData.value = [props.scrollText, props.scrollText] as Text[];
} else {
const text = props.scrollText as Text[];
if (text.length === props.amount) {
textData.value = [...text, ...text];
} else {
textData.value = [...text];
}
}
}
function initScroll() {
if (!props.vertical) {
distance.value = getDistance(); // 获取每列文字宽度
} else {
origin.value = true;
}
cancelRaf(horizontalMoveRaf.value);
cancelRaf(verticalMoveRaf.value);
startMove(); // 开始滚动
}
function getDistance(): number {
return parseFloat((horizontalRef.value.offsetWidth / displayAmount.value).toFixed(2));
}
function startMove() {
if (props.vertical) {
if (textAmount.value > 1) {
cancelRaf(verticalMoveRaf.value);
verticalMove(); // 垂直滚动
}
} else {
if (textAmount.value > displayAmount.value) {
// 超过 amount 条开始滚动
cancelRaf(horizontalMoveRaf.value);
horizonMove(); // 水平滚动
}
}
}
function stopMove() {
// 暂停动画
if (props.vertical) {
cancelRaf(verticalMoveRaf.value);
} else {
cancelRaf(horizontalMoveRaf.value);
}
}
function horizonMove() {
horizontalMoveRaf.value = rafTimeout(
() => {
if (left.value >= distance.value) {
textData.value.push(textData.value.shift() as Text); // 将第一条数据放到最后
left.value = 0;
} else {
left.value += props.step; // 每次移动steppx
}
},
props.interval,
true
);
}
function verticalMove() {
verticalMoveRaf.value = rafTimeout(
() => {
if (origin.value) {
origin.value = false;
}
activeIndex.value = (activeIndex.value + 1) % textAmount.value;
verticalMove();
},
origin.value ? props.verticalInterval : props.verticalInterval + 1000
);
}
function onClick(text: Text) {
// 通知父组件点击的标题
emit('click', text);
}
defineExpose({
start: startMove,
stop: stopMove,
reset: initScrollText
});
</script>
<template>
<div
v-if="!vertical"
ref="horizontalRef"
class="m-slider-horizontal"
:style="[
boardStyle,
`--href-hover-color: ${hrefHoverColor}; --text-gap: ${gap}px; height: ${height}px; width: ${totalWidth};`
]"
>
<div class="m-scroll-view" :style="`will-change: transform; transform: translateX(${-left}px);`">
<component
:is="text.href ? 'a' : 'div'"
class="slide-text"
:class="{ 'href-text': text.href }"
:style="[textStyle, `width: ${distance}px;`]"
v-for="(text, index) in <Text[]>textData"
:key="index"
:title="text.title"
:href="text.href"
:target="text.target"
@mouseenter="stopMove"
@mouseleave="startMove"
@click="onClick(text)"
>
{{ text.title || '--' }}
</component>
</div>
</div>
<div
v-else
ref="verticalRef"
class="m-slider-vertical"
:style="[
boardStyle,
`--href-hover-color: ${hrefHoverColor}; --enter-move: ${height}px; --leave-move: ${-height}px; --tex-gap: ${gap}px; height: ${height}px; width: ${totalWidth};`
]"
>
<TransitionGroup name="slide">
<div class="m-scroll-view" v-for="(text, index) in <Text[]>textData" :key="index" v-show="activeIndex === index">
<component
:is="text.href ? 'a' : 'div'"
class="slide-text"
:class="{ 'href-text': text.href }"
:style="textStyle"
:title="text.title"
:href="text.href"
:target="text.target"
@mouseenter="stopMove"
@mouseleave="startMove"
@click="onClick(text)"
>
{{ text.title || '--' }}
</component>
</div>
</TransitionGroup>
</div>
</template>
<style lang="less" scoped>
// 水平滚动
.m-slider-horizontal {
overflow: hidden;
box-shadow: 0px 0px 5px #d3d3d3;
border-radius: 6px;
background-color: #fff;
.m-scroll-view {
height: 100%;
display: inline-flex;
align-items: center;
.slide-text {
padding-left: var(--text-gap);
font-size: 16px;
font-weight: 400;
color: rgba(0, 0, 0, 0.88);
line-height: 1.57;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.href-text {
cursor: pointer;
transition: color 0.3s;
&:hover {
color: var(--href-hover-color) !important;
}
}
}
}
// 垂直滚动
.slide-enter-active,
.slide-leave-active {
transition: all 1s ease;
}
.slide-enter-from {
transform: translateY(var(--enter-move)) scale(0.5);
opacity: 0;
}
.slide-leave-to {
transform: translateY(var(--leave-move)) scale(0.5);
opacity: 0;
}
.m-slider-vertical {
overflow: hidden;
box-shadow: 0px 0px 5px #d3d3d3;
border-radius: 6px;
background-color: #fff;
position: relative;
.m-scroll-view {
position: absolute;
left: 0;
right: 0;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 0 var(--tex-gap);
.slide-text {
font-size: 16px;
font-weight: 400;
color: rgba(0, 0, 0, 0.88);
line-height: 1.57;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.href-text {
cursor: pointer;
transition: color 0.3s;
&:hover {
color: var(--href-hover-color) !important;
}
}
}
}
</style>

View File

@@ -1,279 +0,0 @@
<script setup lang="ts">
import {computed, nextTick, onMounted, ref, watch} from 'vue';
interface Props {
width?: string | number // 文本域宽度,单位 px
allowClear?: boolean // 可以点击清除图标删除内容
autoSize?: boolean | { minRows?: number; maxRows?: number } // 自适应内容高度
disabled?: boolean // 是否禁用
placeholder?: string // 文本域输入的占位符
maxlength?: number // 文字最大长度
showCount?: boolean // 是否展示字数
value?: string // (v-model) 文本域内容
valueModifiers?: object // 用于访问组件的 v-model 上添加的修饰符
}
const props = withDefaults(defineProps<Props>(), {
width: '100%',
allowClear: false,
autoSize: false,
disabled: false,
placeholder: undefined,
maxlength: undefined,
showCount: false,
value: '',
valueModifiers: () => ({})
});
const textareaRef = ref();
const areaHeight = ref(32);
const textareaWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`;
}
return props.width;
});
const autoSizeStyle = computed(() => {
if (typeof props.autoSize === 'object') {
const style: { 'min-height'?: string; 'max-height'?: string; [propName: string]: any } = {
height: `${areaHeight.value}px`,
resize: 'none'
};
if ('minRows' in props.autoSize) {
style['min-height'] = (props.autoSize.minRows as number) * 22 + 10 + 'px';
}
if ('maxRows' in props.autoSize) {
style['max-height'] = (props.autoSize.maxRows as number) * 22 + 10 + 'px';
}
return style;
}
if (typeof props.autoSize === 'boolean') {
if (props.autoSize) {
return {
height: `${areaHeight.value}px`,
resize: 'none'
};
}
return {};
}
return {};
});
const showClear = computed(() => {
return !props.disabled && props.allowClear && props.value;
});
const showCountNum = computed(() => {
if (props.maxlength) {
return `${props.value.length} / ${props.maxlength}`;
}
return props.value.length;
});
const lazyTextarea = computed(() => {
return 'lazy.ts' in props.valueModifiers;
});
watch(
() => props.value,
async () => {
if (JSON.stringify(autoSizeStyle.value) !== '{}') {
areaHeight.value = 32;
await nextTick();
getAreaHeight();
}
},
{
flush: 'post'
}
);
onMounted(() => {
getAreaHeight();
});
function parseEmojis(text: string): string {
const regex = /\[((1[0-6][0-6]|[1-9]?[0-9])\.gif)]/g; // 匹配 [1.gif] 的字符串
return text.replace(regex, (_match, p1) => {
return `<img width="30px" height="30px" loading="lazy" src="/emoji/qq/gif/${p1}" alt="emoji ${p1}" />`;
});
}
const renderedContent = computed(() => parseEmojis(props.value));
function getAreaHeight() {
areaHeight.value = textareaRef.value.scrollHeight + 2;
}
const emits = defineEmits(['update:value', 'change', 'enter']);
function onInput(e: Event) {
if (!lazyTextarea.value) {
emits('update:value', (e.target as HTMLInputElement).value);
emits('change', e);
}
}
function onChange(e: Event) {
if (lazyTextarea.value) {
emits('update:value', (e.target as HTMLInputElement).value);
emits('change', e);
}
}
async function onKeyboard(e: KeyboardEvent) {
emits('enter', e);
if (lazyTextarea.value) {
textareaRef.value.blur();
await nextTick();
textareaRef.value.focus();
}
}
function onClear() {
emits('update:value', '');
textareaRef.value.focus();
}
</script>
<template>
<div
class="m-textarea"
:class="{ 'show-count': showCount }"
:style="`width: ${textareaWidth};`"
:data-count="showCountNum"
>
<textarea
ref="textareaRef"
type="hidden"
class="u-textarea"
:class="{ 'clear-class': showClear, 'textarea-disabled': disabled }"
:style="autoSizeStyle"
:value="value"
:placeholder="placeholder"
:maxlength="maxlength"
:disabled="disabled"
@input="onInput"
@change="onChange"
@keydown.enter="onKeyboard"
/>
<div v-html="renderedContent" class="rendered-content"></div>
<svg
v-if="showClear"
class="clear-svg"
@click="onClear"
focusable="false"
data-icon="close-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm165.4 618.2l-66-.3L512 563.4l-99.3 118.4-66.1.3c-4.4 0-8-3.5-8-8 0-1.9.7-3.7 1.9-5.2l130.1-155L340.5 359a8.32 8.32 0 01-1.9-5.2c0-4.4 3.6-8 8-8l66.1.3L512 464.6l99.3-118.4 66-.3c4.4 0 8 3.5 8 8 0 1.9-.7 3.7-1.9 5.2L553.5 514l130 155c1.2 1.5 1.9 3.3 1.9 5.2 0 4.4-3.6 8-8 8z"
></path>
</svg>
</div>
</template>
<style lang="less" scoped>
.m-textarea {
position: relative;
display: inline-block;
.u-textarea {
width: 100%;
min-width: 0;
min-height: 32px;
max-width: 100%;
height: auto;
padding: 4px 11px;
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
line-height: 1.5714285714285714;
list-style: none;
transition: all 0.3s,
height 0s;
resize: vertical;
position: relative;
z-index: 9;
display: inline-block;
vertical-align: bottom;
text-overflow: ellipsis;
background-color: #ffffff;
border: 1px solid #d9d9d9;
border-radius: 6px;
outline: none;
&:hover {
border-color: #4096ff;
z-index: 1;
}
&:focus-within {
border-color: #4096ff;
box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
outline: 0;
}
}
.clear-class {
padding-right: 24px;
}
textarea:disabled {
color: rgba(0, 0, 0, 0.25);
}
textarea::-webkit-input-placeholder {
color: rgba(0, 0, 0, 0.25);
}
textarea:-moz-placeholder {
color: rgba(0, 0, 0, 0.25);
}
textarea::-moz-placeholder {
color: rgba(0, 0, 0, 0.25);
}
textarea:-ms-input-placeholder {
color: rgba(0, 0, 0, 0.25);
}
.clear-svg {
position: absolute;
top: 9px;
right: 8px;
z-index: 99;
display: inline-block;
font-size: 12px;
color: rgba(0, 0, 0, 0.25);
fill: currentColor;
cursor: pointer;
transition: color 0.3s;
&:hover {
color: rgba(0, 0, 0, 0.45);
}
}
.textarea-disabled {
color: rgba(0, 0, 0, 0.25);
background-color: rgba(0, 0, 0, 0.04);
cursor: not-allowed;
&:hover {
border-color: #d9d9d9;
}
&:focus-within {
border-color: #d9d9d9;
box-shadow: none;
}
}
}
.show-count {
&::after {
color: rgba(0, 0, 0, 0.45);
white-space: nowrap;
content: attr(data-count);
pointer-events: none;
float: right;
}
}
</style>

View File

@@ -1,185 +0,0 @@
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue';
interface Data {
desc: string // 文字描述 string | slot
color?: 'blue' | 'green' | 'red' | 'gray' | string // 圆圈颜色,默认值 blue
}
interface Props {
timelineData?: Data[] // 时间轴内容数组
width?: number | string // 时间轴区域总宽度,单位 px
lineStyle?: 'solid' | 'dashed' | 'dotted' // 时间线样式
mode?: 'left' | 'center' | 'right' // 通过设置 mode 可以改变时间轴和内容的相对位置
position?: 'left' | 'right' // 当 mode 为 center 时内容交替展现内容从左边left开始或者右边right开始展现
}
const props = withDefaults(defineProps<Props>(), {
timelineData: () => [],
width: '100%',
lineStyle: 'solid',
mode: 'left',
position: 'left'
});
enum ColorStyle { // 颜色主题对象
blue = '#1677ff',
green = '#52c41a',
red = '#ff4d4f',
gray = '#00000040'
}
const desc = ref();
const dotsHeight = ref<string[]>([]);
const totalWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`;
} else {
return props.width;
}
});
const len = computed(() => {
return props.timelineData.length;
});
function getDotsHeight() {
for (let n = 0; n < len.value; n++) {
dotsHeight.value[n] = getComputedStyle(desc.value[n].firstElementChild || desc.value[n], null).getPropertyValue(
'line-height'
);
}
}
watchEffect(
() => {
getDotsHeight();
},
{ flush: 'post' }
);
watchEffect(
() => {
if (props.mode === 'center') {
for (let n = 0; n < len.value; n++) {
if ((n + 1) % 2) {
// odd
if (props.position === 'left') {
desc.value[n].classList.add('desc-alternate-left');
} else {
desc.value[n].classList.add('desc-alternate-right');
}
} else {
// even
if (props.position === 'left') {
desc.value[n].classList.add('desc-alternate-right');
} else {
desc.value[n].classList.add('desc-alternate-left');
}
}
}
}
},
{ flush: 'post' }
);
</script>
<template>
<div class="m-timeline" :style="`width: ${totalWidth};`">
<div
class="timeline-item"
:class="{ 'item-last': index === timelineData.length - 1 }"
v-for="(data, index) in timelineData"
:key="index"
>
<span class="timeline-tail" :class="`tail-${mode}`" :style="`border-left-style: ${lineStyle};`"></span>
<div class="timeline-dot" :class="`dot-${mode}`" :style="`height: ${dotsHeight[index]}`">
<slot name="dot" :index="index">
<span class="dot-item" v-if="data.color === 'red'" :style="{ borderColor: ColorStyle.red }"></span>
<span class="dot-item" v-else-if="data.color === 'gray'" :style="{ borderColor: ColorStyle.gray }"></span>
<span class="dot-item" v-else-if="data.color === 'green'" :style="{ borderColor: ColorStyle.green }"></span>
<span class="dot-item" v-else-if="data.color === 'blue'" :style="{ borderColor: ColorStyle.blue }"></span>
<span class="dot-item" v-else :style="{ borderColor: data.color || ColorStyle.blue }"></span>
</slot>
</div>
<div ref="desc" :class="`timeline-desc desc-${mode}`">
<slot name="desc" :index="index">{{ data.desc || '--' }}</slot>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.m-timeline {
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
.timeline-item {
position: relative;
padding-bottom: 30px;
.timeline-tail {
position: absolute;
top: 12px;
width: 0;
height: 100%;
border-left-width: 2px;
border-left-color: #e8e8e8;
}
.tail-left {
left: 5px;
}
.tail-center {
left: 0;
right: 0;
margin: 0 auto;
}
.tail-right {
right: 5px;
}
.timeline-dot {
position: absolute;
display: flex;
align-items: center;
:deep(svg) {
fill: currentColor;
}
.dot-item {
display: inline-block;
width: 12px;
height: 12px;
border-width: 2px;
border-style: solid;
border-radius: 50%;
background: #fff;
}
}
.dot-left {
left: 6px;
transform: translateX(-50%);
}
.dot-center {
left: 50%;
transform: translateX(-50%);
}
.dot-right {
right: 6px;
transform: translateX(50%);
}
.timeline-desc {
font-size: 14px;
line-height: 1.5714285714285714;
word-break: break-all;
}
.desc-left {
margin-left: 25px;
}
.desc-center {
width: calc(50% - 12px);
}
.desc-alternate-left {
text-align: end;
}
.desc-alternate-right {
margin-left: calc(50% + 12px);
}
.desc-right {
margin-right: 25px;
text-align: end;
}
}
.item-last {
.timeline-tail {
display: none;
}
}
}
</style>

View File

@@ -1,285 +0,0 @@
<script setup lang="ts">
import {computed, onMounted, ref, watch} from 'vue';
/*
按需引入
*/
// 使用 ECharts 提供的按需引入的接口来打包必须的组件
// 引入 echarts 核心模块,核心模块提供了 echarts 使用必须要的接口
import * as echarts from 'echarts/core';
// 引入树图图表,图表后缀都为 Chart
import {TreeChart} from 'echarts/charts';
// 引入提示框,组件后缀都为 Component
import {TooltipComponent} from 'echarts/components';
// 引入 Canvas 渲染器,注意引入 CanvasRenderer 或者 SVGRenderer 是必须的一步
import {CanvasRenderer} from 'echarts/renderers';
// 注册必须的组件
echarts.use([TreeChart, TooltipComponent, CanvasRenderer]);
/*
全部引入
*/
// import * as echarts from 'echarts'
/*
需要注意的是为了保证打包的体积是最小的ECharts 按需引入的时候不再提供任何渲染器,
所以需要选择引入 CanvasRenderer 或者 SVGRenderer 作为渲染器。这样的好处是假如
你只需要使用 svg 渲染模式,打包的结果中就不会再包含无需使用的 CanvasRenderer 模块
*/
const chart = ref();
const treeChart = ref();
var option: any;
interface Tree {
name: string // 数据项名称
value?: number // 数据值
[propName: string]: any // 添加一个字符串索引签名,用于包含带有任意数量的其他属性
}
interface Props {
treeData: Tree[] // 树图数据源
width?: string | number // 容器宽度
height?: string | number // 容器高度
themeColor?: string // 主题色
edgeShape?: 'curve' | 'polyline' // 树图边的形状有曲线curve和折线polyline两种只有正交布局下生效
}
const props = withDefaults(defineProps<Props>(), {
treeData: () => ([]),
width: '100%',
height: '100%',
themeColor: '#1677FF',
edgeShape: 'curve'
});
const chartWidth = computed(() => {
if (typeof props.width === 'number') {
return props.width + 'px';
} else {
return props.width;
}
});
const chartHeight = computed(() => {
if (typeof props.height === 'number') {
return props.height + 'px';
} else {
return props.height;
}
});
onMounted(() => {
initChart(); // 初始化图标示例
});
watch(
() => props.treeData,
(to) => {
// 监听并更新图例数据
option.series[0].data = to;
treeChart.value.setOption(option);
},
{
deep: true
}
);
watch(
() => [props.width, props.height, props.themeColor, props.edgeShape],
() => {
if (treeChart.value) {
treeChart.value.dispose(); // 销毁实例
}
initChart(); // 重新初始化实例
},
{
deep: true,
flush: 'post'
}
);
// const loadingConfig = {
// text: 'loading',
// color: '#c23531',
// textColor: '#000',
// maskColor: 'rgba(255, 255, 255, 0.8)',
// zlevel: 0,
// 字体大小。从 `v4.8.0` 开始支持。
// fontSize: 12,
// 是否显示旋转动画spinner。从 `v4.8.0` 开始支持。
// showSpinner: true,
// 旋转动画spinner的半径。从 `v4.8.0` 开始支持。
// spinnerRadius: 20,
// 旋转动画spinner的线宽。从 `v4.8.0` 开始支持。
// lineWidth: 5,
// 字体粗细。从 `v5.0.1` 开始支持。
// fontWeight: 'normal',
// 字体风格。从 `v5.0.1` 开始支持。
// fontStyle: 'normal',
// 字体系列。从 `v5.0.1` 开始支持。
// fontFamily: 'sans-serif'
// }
function showLoading(config: any) {
treeChart.value.showLoading('default', {text: '', color: props.themeColor, ...config}); // 显示加载动画效果
}
function hideLoading() {
treeChart.value.hideLoading(); // 隐藏动画加载效果
}
defineExpose({
showLoading,
hideLoading
});
const emit = defineEmits(['clickNode']);
function onClick(e: any) {
emit('clickNode', e.data);
}
function initChart() {
// 等价于使用 Canvas 渲染器默认echarts.init(containerDom, null, { renderer: 'canvas' })
treeChart.value = echarts.init(chart.value);
option = {
tooltip: { // 提示框浮层设置
trigger: 'item',
triggerOn: 'mousemove', // 提示框触发条件
enterable: true, // 鼠标是否可进入提示框浮层中默认false
confine: true, // 是否将tooltip框限制在图表的区域内
formatter: function (params: any) { // 提示框浮层内容格式器,支持字符串模板和回调函数两种形式
// console.log('params:', params)
return params.marker + params.name + '<br/>' + '$ ' + (params.value || '--');
},
// valueFormatter: function (value) { // tooltip 中数值显示部分的格式化回调函数
// return '$' + value.toFixed(2)
// },
backgroundColor: '#FFF', // 提示框浮层的背景颜色
borderColor: props.themeColor, // 提示框浮层的边框颜色
borderWidth: 1, // 提示框浮层的边框宽
borderRadius: 8, // 提示框浮层圆角
padding: [6, 8], // 提示框浮层的内边距
textStyle: { // 提示框浮层的文本样式
color: '#333', // 文字颜色
fontWeight: 400, // 字体粗细
fontSize: 16, // 字体大小
lineHeight: 20, // 行高
width: 60, // 文本显示宽度
// 文字超出宽度是否截断或者换行只有配置width时有效
overflow: 'breakAll', // truncate截断并在末尾显示ellipsis配置的文本默认为...;break换行;breakAll换行并强制单词内换行
ellipsis: '...'
},
extraCssText: 'box-shadow: 0 0 9px rgba(0, 0, 0, 0.3); text-align: right;' // 额外添加到浮层的css样式
},
series: [
{
type: 'tree',
data: props.treeData,
name: '树图',
top: '1%', // 组件离容器上侧的距离像素值20或相对容器的百分比20%
left: '10%', // 组件离容器左侧的距离
bottom: '1%', // 组件离容器下侧的距离
right: '16%', // 组件离容器右侧的距离
layout: 'orthogonal', // 树图的布局正交orthogonal和径向radial两种
orient: 'LR', // 树图中正交布局的方向,'LR','RL','TB','BT',只有布局是正交时才生效
edgeShape: props.edgeShape, // 树图边的形状有曲线curve和折线polyline两种只有正交布局下生效
roam: false, // 是否开启鼠标缩放或平移默认false
initialTreeDepth: 2, // 树图初始的展开层级深度根节点是0不设置时全部展开
// symbol: 'arrow', // 标记的图形默认是emptyCircle;circle,rect,roundRect,triangle,diamond,pin,arrow,none
// symbolRotate: 270, // 配合arrow图形使用效果较好
symbolSize: 16, // 大于0时是圆圈等于0时不展示标记的大小
itemStyle: { // 树图中每个节点的样式
color: props.themeColor, // 节点未展开时的填充色
borderColor: 'rgba(255, 144, 0, 1)', // 图形的描边颜色
borderWidth: 1, // 描边线宽为0时无描边
borderType: 'dotted', // 描边类型
borderCap: 'square', // 指定线段末端的绘制方式butt方形结束round圆形结束square
shadowColor: 'rgba(0,121,221,0.3)', // 阴影颜色
shadowBlur: 16, // 图形阴影的模糊大小
opacity: 1 // 图形透明度
},
label: { // 每个节点对应的文本标签样式
show: true, // 是否显示标签
distance: 8, // 文本距离图形元素的距离
position: 'left', // 标签位置
verticalAlign: 'middle', // 文字垂直对齐方式默认自动topmiddlebottom
align: 'center', // 文字水平对齐方式默认自动leftrightcenter
fontSize: 16, // 字体大小
color: '#333', // 字体颜色
backgroundColor: '#F0F5FA', // 文字块的背景颜色
borderColor: props.themeColor, // 文字块边框颜色
borderWidth: 1, // 文字块边框宽度
borderType: 'solid', // 文字块边框描边类型 solid dashed dotted
borderRadius: 4, // 文字块的圆角
padding: [6, 12], // 文字块内边距
shadowColor: 'rgba(0,121,221,0.3)', // 文字块的背景阴影颜色
shadowBlur: 6, // 文字块的背景阴影长度
width: 60,
// 文字超出宽度是否截断或者换行只有配置width时有效
overflow: 'truncate', // truncate截断并在末尾显示ellipsis配置的文本默认为...;break换行;breakAll换行并强制单词内换行
ellipsis: '...'
},
lineStyle: { // 树图边的样式
color: 'rgba(0,0,0,.35)', // 树图边的颜色
width: 2, // 树图边的宽度
curveness: 0.5, // 树图边的曲度
shadowColor: 'rgba(0, 0, 0, 0.5)', // 阴影颜色
shadowBlur: 10 // 图形阴影的模糊大小
},
emphasis: { // 树图中图形和标签高亮的样式
disabled: false, // 是否关闭高亮状态默认false
// 在高亮图形时,是否淡出其它数据的图形已达到聚焦的效果
focus: 'self', // none不淡出其他图形默认self只聚焦当前高亮的数据图形series聚焦当前高亮的数据所在系列的所有图形ancestor聚焦所有祖先节点descendant聚焦所有子孙节点relative聚焦所有子孙和祖先节点
blurScope: 'coordinateSystem', // 开启focus时配置淡出的范围coordinateSystem淡出范围为坐标系默认series淡出范围为系列global淡出范围为全局
itemStyle: { // 该节点的样式
color: props.themeColor, // 图形的颜色
// borderColor: 'rgba(255, 144, 0, 1)', // 图形的描边颜色
borderWidth: 1, // 描边线宽为0时无描边
borderType: 'solid', // 描边类型 solid dashed dotted
borderCap: 'square', // 指定线段末端的绘制方式butt方形结束round圆形结束square
shadowColor: 'rgba(0,121,221,0.3)', // 阴影颜色
shadowBlur: 12, // 图形阴影的模糊大小
opacity: 1 // 图形透明度
},
lineStyle: { // 树图边的样式
color: 'rgba(0,0,0,.45)', // 树图边的颜色
width: 2, // 树图边的宽度
curveness: 0.5, // 树图边的曲度
shadowColor: 'rgba(0, 0, 0, 0.5)', // 阴影颜色
shadowBlur: 6 // 图形阴影的模糊大小
},
label: { // 高亮标签的文本样式
color: '#333',
fontWeight: 600
}
},
blur: { // 淡出状态的相关配置开启emphasis.focus后有效
itemStyle: {}, // 节点的样式
lineStyle: {}, // 树图边的样式
label: {} // 淡出标签的文本样式
},
leaves: { // 叶子节点的特殊配置
label: { // 叶子节点的文本标签样式
distance: 8,
// color: props.themeColor,
position: 'right',
verticalAlign: 'middle',
align: 'left'
},
itemStyle: {}, // 叶子节点的样式
emphasis: {}, // 叶子节点高亮状态的配置
blur: {}, // 叶子节点淡出状态的配置
select: {} // 叶子节点选中状态的配置
},
animation: true, // 是否开启动画
expandAndCollapse: true, // 子树折叠和展开的交互,默认打开
animationDuration: 550, // 初始动画的时长
animationEasing: 'linear', // 初始动画的缓动效果
animationDelay: 0, // 初始动画的延迟
animationDurationUpdate: 750, // 数据更新动画的时长
animationEasingUpdate: 'cubicInOut', // 数据更新动画的缓动效果
animationDelayUpdate: 0 // 数据更新动画的延迟
}
]
};
if (option) {
treeChart.value.setOption(option);
}
// 监听树图节点的点击事件
treeChart.value.on('click', onClick);
}
</script>
<template>
<div ref="chart" :style="`width: ${chartWidth}; height: ${chartHeight};`"></div>
</template>

View File

@@ -1,590 +0,0 @@
<script setup lang="ts">
import {computed, ref, watchEffect} from 'vue';
// import Spin from '../Spin/Spin.vue';
// import Message from '../Message/Message.vue';
// import Image from '../Image/Image.vue';
// import Space from '../Space/Space.vue';
interface FileType {
name?: string // 文件名
url: any // 文件地址
[propName: string]: any // 添加一个字符串索引签名,用于包含带有任意数量的其他属性
}
interface MessageType {
upload?: string // 上传成功的消息提示,没有设置该属性时即不显示上传消息提示
remove?: string // 删除成功的消息提示,没有设置该属性时即不显示删除消息提示
}
interface Props {
accept?: string // 接受上传的文件类型,与 <input type="file" /> 的 accept 属性一致,参考 https://developer.mozilla.org/zh-CN/docs/Web/HTML/Attributes/accept
multiple?: boolean // 是否支持多选文件,开启后可选择多个文件
maxCount?: number // 限制上传数量。当为 1 时,始终用最新上传的文件代替当前文件
tip?: string // 上传描述文字 string | slot
fit?: 'contain' | 'fill' | 'cover' | 'none' | 'scale-down' // 预览图片缩放规则,仅当上传文件为图片时生效
draggable?: boolean // 是否支持拖拽上传,开启后可拖拽文件到选择框上传
disabled?: boolean // 是否禁用,只能预览,不能删除和上传
spaceProps?: object // Space 组件属性配置,用于配置多个文件时的排列方式
spinProps?: object // Spin 组件属性配置,用于配置上传中样式
imageProps?: object // Image 组件属性配置,用于配置图片预览
messageProps?: object // Message 组件属性配置,用于配置操作消息提示
actionMessage?: MessageType // 操作完成的消息提示,传 {} 即可不显示任何消息提示
beforeUpload?: any // 上传文件之前的钩子,参数为上传的文件,返回 false 则停止上传,返回 true 开始上传;支持返回一个 Promise 对象如服务端校验等Promise 对象 reject 时停止上传resolve 时开始上传;通常用来校验用户上传的文件格式和大小
uploadMode?: 'base64' | 'custom' // 上传文件的方式,默认是 base64可选 'base64' | 'custom'
customRequest?: any // 自定义上传行为,只有 uploadMode: custom 时,才会使用 customRequest 自定义上传行为
fileList?: FileType[] // (v-model) 已上传的文件列表
}
const props = withDefaults(defineProps<Props>(), {
accept: '*', // 默认支持所有类型
multiple: false,
maxCount: undefined,
tip: 'ImageUpload',
fit: 'contain',
draggable: true,
disabled: false,
spaceProps: () => ({}),
spinProps: () => ({}),
imageProps: () => ({}),
messageProps: () => ({}),
actionMessage: () => ({upload: '上传成功', remove: '删除成功'}),
beforeUpload: () => true,
uploadMode: 'base64',
customRequest: () => {
},
fileList: () => []
});
const uploadedFiles = ref<FileType[]>([]); // 上传文件列表
const showUpload = ref(1); // 展示的上传框
const uploading = ref<boolean[]>([]); // 上传中
const uploadInputRef = ref(); // 上传文件控件引用
const imageRef = ref();
const messageRef = ref();
const emits = defineEmits(['update:fileList', 'drop', 'change', 'preview', 'remove']);
const maxFileCount = computed(() => {
if (props.maxCount === undefined) {
return Infinity;
}
return props.maxCount;
});
watchEffect(() => {
initUpload();
});
function initUpload() {
uploadedFiles.value = [...props.fileList];
if (uploadedFiles.value.length > maxFileCount.value) {
uploadedFiles.value.splice(maxFileCount.value);
}
if (props.disabled) {
// 禁用
showUpload.value = uploadedFiles.value.length;
} else {
if (uploadedFiles.value.length < maxFileCount.value) {
showUpload.value = props.fileList.length + 1;
} else {
showUpload.value = maxFileCount.value;
}
}
}
function isImage(url: string) {
// 检查 url 是否为图片
const imageUrlReg = /\.(jpg|jpeg|png|gif)$/i;
const base64Reg = /^data:image/;
return imageUrlReg.test(url) || base64Reg.test(url);
}
function isPDF(url: string) {
// 检查 url 是否为pdf
const pdfUrlReg = /\.pdf$/i;
const base64Reg = /^data:application\/pdf/;
return pdfUrlReg.test(url) || base64Reg.test(url);
}
function onDrop(e: DragEvent, index: number) {
// 拖拽上传
const files = e.dataTransfer?.files;
if (files?.length) {
const len = files.length;
for (let n = 0; n < len; n++) {
if (index + n <= maxFileCount.value) {
uploadFile(files[n], index + n);
} else {
break;
}
}
// input的change事件默认保存上一次input的value值同一value值(根据文件路径判断)在上传时不重新加载
uploadInputRef.value[index].value = '';
}
emits('drop', e);
}
function onClickFileInput(index: number) {
uploadInputRef.value[index].click();
}
function onUpload(e: any, index: number) {
// 点击上传
const files = e.target.files;
if (files?.length) {
const len = files.length;
for (let n = 0; n < len; n++) {
if (index + n < maxFileCount.value) {
uploadFile(files[n], index + n);
} else {
break;
}
}
// input的change事件默认保存上一次input的value值同一value值(根据文件路径判断)在上传时不重新加载
uploadInputRef.value[index].value = '';
}
}
const uploadFile = async (file: File, index: number) => {
// 统一上传文件方法
// console.log('开始上传 upload-event files:', file)
const promiseFunction = () => {
return new Promise((resolve, reject) => {
try {
// 尝试执行传入的函数,并获取返回值
const result = props.beforeUpload(file);
// 检查返回值是否是 Promise
if (result instanceof Promise) {
// 如果是 Promise则等待其 resolve 或 reject
result.then(resolve, reject);
} else {
// 检查返回值是否为布尔值
if (typeof result === 'boolean') {
// 如果是布尔值根据值resolve或reject
if (result) {
resolve(result);
} else {
reject(new Error('Function returned false'));
}
// result ? resolve(result) : reject(new Error('Function returned false'));
} else {
// 否则直接resolve返回值
resolve(result);
}
}
} catch (error) {
// 如果执行过程中抛出错误,则 reject 错误
reject(error);
}
});
};
promiseFunction()
.then(() => {
// 使用用户钩子进行上传前文件判断,例如大小、类型限制
if (maxFileCount.value > showUpload.value) {
showUpload.value++;
}
if (props.uploadMode === 'base64') {
// 以base64方式读取文件
uploading.value[index] = true;
base64Upload(file, index);
}
if (props.uploadMode === 'custom') {
// 自定义上传行为,需配合 customRequest 属性
uploading.value[index] = true;
customUpload(file, index);
}
})
.catch((error: any) => {
console.log('beforeUpload error:', error);
});
};
function base64Upload(file: File, index: number) {
var reader = new FileReader();
reader.readAsDataURL(file); // 以base64方式读取文件
reader.onloadstart = function (_e) {
// 当读取操作开始时触发
// reader.abort() // 取消上传
// console.log('开始读取 onloadstart:', e)
};
reader.onabort = function (_e) {
// 当读取操作被中断时触发
// console.log('读取中止 onabort:', e)
};
reader.onerror = function (_e) {
// 当读取操作发生错误时触发
// console.log('读取错误 onerror:', e)
};
reader.onprogress = function (e) {
// 在读取Blob时触发读取上传进度50ms左右调用一次
// console.log('读取中 onprogress:', e)
// console.log('已读取:', Math.ceil(e.loaded / e.total * 100))
if (e.loaded === e.total) {
// 上传完成
uploading.value[index] = false; // 隐藏loading状态
}
};
reader.onload = function (e) {
// 当读取操作成功完成时调用
// console.log('读取成功 onload:', e)
// 该文件的base64数据如果是图片则前端可直接用来展示图片
uploadedFiles.value.push({
name: file.name,
url: e.target?.result
});
if (props.actionMessage.upload) {
messageRef.value.success(props.actionMessage.upload);
}
emits('update:fileList', uploadedFiles.value);
emits('change', uploadedFiles.value);
};
reader.onloadend = function (_e) {
// 当读取操作结束时触发(要么成功,要么失败)触发
// console.log('读取结束 onloadend:', e)
};
}
function customUpload(file: File, index: number) {
props
.customRequest(file)
.then((res: any) => {
uploadedFiles.value.push(res);
if (props.actionMessage.upload) {
messageRef.value.success(props.actionMessage.upload);
}
emits('update:fileList', uploadedFiles.value);
emits('change', uploadedFiles.value);
})
.catch((err: any) => {
if (maxFileCount.value > 1) {
showUpload.value = uploadedFiles.value.length + 1;
}
messageRef.value.error(err);
})
.finally(() => {
uploading.value[index] = false;
});
}
function onPreview(index: number, url: string) {
if (isImage(url)) {
const files = uploadedFiles.value.slice(0, index).filter((file) => !isImage(file.url));
imageRef.value[index - files.length].preview(0);
} else {
window.open(url);
}
emits('preview', uploadedFiles.value[index]);
}
function onRemove(index: number) {
if (uploadedFiles.value.length < maxFileCount.value) {
showUpload.value--;
}
const removeFile = uploadedFiles.value.splice(index, 1);
if (props.actionMessage.remove) {
messageRef.value.success(props.actionMessage.remove);
}
emits('remove', removeFile[0]);
emits('update:fileList', uploadedFiles.value);
emits('change', uploadedFiles.value);
}
function onInfo(content: string) {
messageRef.value.info(content);
}
function onSuccess(content: string) {
messageRef.value.success(content);
}
function onError(content: string) {
messageRef.value.error(content);
}
function onWarning(content: string) {
messageRef.value.warning(content);
}
function onLoading(content: string) {
messageRef.value.loading(content);
}
defineExpose({
info: onInfo,
success: onSuccess,
error: onError,
warning: onWarning,
loading: onLoading
});
</script>
<template>
<div class="m-upload-wrap">
<Space gap="small" v-bind="spaceProps">
<div class="m-upload-item" v-for="n of showUpload" :key="n">
<div class="m-upload">
<div
v-show="!uploading[n - 1] && !uploadedFiles[n - 1]"
class="upload-item"
:class="{ 'upload-disabled': disabled }"
@dragenter.stop.prevent
@dragover.stop.prevent
@drop.stop.prevent="draggable && !disabled ? onDrop($event, n - 1) : () => false"
@click="!disabled ? onClickFileInput(n - 1) : () => false"
>
<input
ref="uploadInputRef"
type="file"
@click.stop
:accept="accept"
:multiple="multiple"
@change="onUpload($event, n - 1)"
style="display: none"
/>
<div>
<svg
focusable="false"
class="plus-svg"
data-icon="plus"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<defs></defs>
<path d="M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z"></path>
<path d="M176 474h672q8 0 8 8v60q0 8-8 8H176q-8 0-8-8v-60q0-8 8-8z"></path>
</svg>
<p class="upload-tip">
<slot>{{ tip }}</slot>
</p>
</div>
</div>
<div v-show="uploading[n - 1]" class="file-uploading">
<Spin class="spin-uploading" tip="uploading" size="small" indicator="spin-line" v-bind="spinProps"/>
</div>
<div v-if="uploadedFiles[n - 1]" class="file-preview">
<Image
v-if="isImage(uploadedFiles[n - 1].url)"
ref="imageRef"
:bordered="false"
:width="82"
:height="82"
:fit="fit"
:src="uploadedFiles[n - 1].url"
:name="uploadedFiles[n - 1].name"
v-bind="imageProps"
/>
<svg
v-else-if="isPDF(uploadedFiles[n - 1].url)"
class="file-svg"
focusable="false"
data-icon="file-pdf"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M531.3 574.4l.3-1.4c5.8-23.9 13.1-53.7 7.4-80.7-3.8-21.3-19.5-29.6-32.9-30.2-15.8-.7-29.9 8.3-33.4 21.4-6.6 24-.7 56.8 10.1 98.6-13.6 32.4-35.3 79.5-51.2 107.5-29.6 15.3-69.3 38.9-75.2 68.7-1.2 5.5.2 12.5 3.5 18.8 3.7 7 9.6 12.4 16.5 15 3 1.1 6.6 2 10.8 2 17.6 0 46.1-14.2 84.1-79.4 5.8-1.9 11.8-3.9 17.6-5.9 27.2-9.2 55.4-18.8 80.9-23.1 28.2 15.1 60.3 24.8 82.1 24.8 21.6 0 30.1-12.8 33.3-20.5 5.6-13.5 2.9-30.5-6.2-39.6-13.2-13-45.3-16.4-95.3-10.2-24.6-15-40.7-35.4-52.4-65.8zM421.6 726.3c-13.9 20.2-24.4 30.3-30.1 34.7 6.7-12.3 19.8-25.3 30.1-34.7zm87.6-235.5c5.2 8.9 4.5 35.8.5 49.4-4.9-19.9-5.6-48.1-2.7-51.4.8.1 1.5.7 2.2 2zm-1.6 120.5c10.7 18.5 24.2 34.4 39.1 46.2-21.6 4.9-41.3 13-58.9 20.2-4.2 1.7-8.3 3.4-12.3 5 13.3-24.1 24.4-51.4 32.1-71.4zm155.6 65.5c.1.2.2.5-.4.9h-.2l-.2.3c-.8.5-9 5.3-44.3-8.6 40.6-1.9 45 7.3 45.1 7.4zm191.4-388.2L639.4 73.4c-6-6-14.1-9.4-22.6-9.4H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V311.3c0-8.5-3.4-16.7-9.4-22.7zM790.2 326H602V137.8L790.2 326zm1.8 562H232V136h302v216a42 42 0 0042 42h216v494z"
></path>
</svg>
<svg
v-else
class="file-svg"
focusable="false"
data-icon="file"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M854.6 288.6L639.4 73.4c-6-6-14.1-9.4-22.6-9.4H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V311.3c0-8.5-3.4-16.7-9.4-22.7zM790.2 326H602V137.8L790.2 326zm1.8 562H232V136h302v216a42 42 0 0042 42h216v494z"
></path>
</svg>
<div class="file-mask">
<a class="file-icon" title="预览" @click="onPreview(n - 1, uploadedFiles[n - 1].url)">
<svg
class="icon-svg"
focusable="false"
data-icon="eye"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M942.2 486.2C847.4 286.5 704.1 186 512 186c-192.2 0-335.4 100.5-430.2 300.3a60.3 60.3 0 000 51.5C176.6 737.5 319.9 838 512 838c192.2 0 335.4-100.5 430.2-300.3 7.7-16.2 7.7-35 0-51.5zM512 766c-161.3 0-279.4-81.8-362.7-254C232.6 339.8 350.7 258 512 258c161.3 0 279.4 81.8 362.7 254C791.5 684.2 673.4 766 512 766zm-4-430c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176zm0 288c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z"
></path>
</svg>
</a>
<a v-show="!disabled" class="file-icon" title="删除" @click.prevent.stop="onRemove(n - 1)">
<svg
class="icon-svg"
focusable="false"
data-icon="delete"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z"
></path>
</svg>
</a>
</div>
</div>
</div>
</div>
</Space>
<Message ref="messageRef" v-bind="messageProps"/>
</div>
</template>
<style lang="less" scoped>
.m-upload-wrap {
display: inline-block;
.m-upload-item {
display: inline-block;
}
.mr8 {
margin-right: 8px;
}
}
.m-upload {
position: relative;
display: inline-block;
width: 100px;
height: 100px;
.upload-item {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
width: 100px;
height: 100px;
border-radius: 8px;
border: 1px dashed #d9d9d9;
background-color: rgba(0, 0, 0, 0.02);
cursor: pointer;
transition: border-color 0.3s;
&:hover {
border-color: #40a9ff;
}
.plus-svg {
display: inline-block;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
fill: currentColor;
}
.upload-tip {
margin-top: 8px;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
}
}
.upload-disabled {
cursor: not-allowed;
&:hover {
border-color: #d9d9d9;
}
}
.file-uploading {
width: 100px;
height: 100px;
padding: 8px;
border-radius: 8px;
border: 1px dashed #d9d9d9;
background-color: rgba(0, 0, 0, 0.02);
display: flex;
align-items: center;
text-align: center;
.spin-uploading {
display: inline-block;
:deep(.spin-tip) {
max-width: 82px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.file-preview {
position: relative;
padding: 8px;
width: 100px;
height: 100px;
border-radius: 8px;
border: 1px solid #d9d9d9;
display: flex;
align-items: center;
text-align: center;
.file-svg {
display: inline-block;
width: 100%;
height: 60px;
color: rgba(0, 0, 0, 0.88);
fill: currentColor;
}
.file-mask {
// top right bottom left 简写为 inset: 0
// insert 无论元素的书写模式、行内方向和文本朝向如何,其所定义的都不是逻辑偏移而是实体偏移
position: absolute;
inset: 0;
margin: 8px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
.file-icon {
display: inline-block;
height: 16px;
margin: 0 4px;
cursor: pointer;
.icon-svg {
display: inline-block;
font-size: 16px;
color: rgba(255, 255, 255, 0.65);
fill: currentColor;
cursor: pointer;
transition: color 0.3s;
&:hover {
color: rgba(255, 255, 255, 1);
}
}
}
}
&:hover {
.file-mask {
opacity: 1;
pointer-events: auto;
}
}
}
}
</style>

View File

@@ -1,217 +0,0 @@
<script setup lang="ts">
import {computed, onMounted, ref} from 'vue';
interface Props {
src?: string // 视频文件地址,支持网络地址 https 和相对地址
poster?: string // 视频封面地址,支持网络地址 https 和相对地址
second?: number // 在未设置封面时,自动截取视频第 second 秒对应帧作为视频封面,单位 s
width?: string | number // 视频播放器宽度,单位 px
height?: string | number // 视频播放器高度,单位 px
autoplay?: boolean // 视频就绪后是否马上播放,优先级高于 preload
controls?: boolean // 是否向用户显示控件,比如进度条,全屏等
loop?: boolean // 视频播放完成后,是否循环播放
muted?: boolean // 是否静音
preload?: 'auto' | 'metadata' | 'none' // 是否在页面加载后载入视频,如果设置了 autoplay 属性,则 preload 将被忽略
showPlay?: boolean // 播放暂停时是否显示播放器中间的暂停图标
fit?: 'none' | 'fill' | 'contain' | 'cover' // video 的 poster 默认图片和视频内容缩放规则
}
const props = withDefaults(defineProps<Props>(), {
src: undefined,
poster: undefined,
second: 0.5,
width: 800,
height: 450,
/*
参考 MDN 自动播放指南https://developer.mozilla.org/zh-CN/docs/Web/Media/Autoplay_guide
Autoplay 功能
据新政策,媒体内容将在满足以下至少一个的条件下自动播放:
1.音频被静音或其音量设置为 0
2.用户和网页已有交互行为(包括点击、触摸、按下某个键等等)
3.网站已被列入白名单;如果浏览器确定用户经常与媒体互动,这可能会自动发生,也可能通过首选项或其他用户界面功能手动发生
4.自动播放策略应用到<iframe>或者其文档上
autoplay由于目前在最新版的Chrome浏览器以及所有以Chromium为内核的浏览器
已不再允许自动播放音频和视频。就算你为video或audio标签设置了autoplay属性也一样不能自动播放
解决方法:设置视频 autoplay 时,视频必须设置为静音 muted: true 即可实现自动播放,
然后用户可以使用控制栏开启声音,类似某宝商品自动播放的宣传视频逻辑
*/
autoplay: false,
controls: true,
loop: false,
muted: false,
/*
preload可选属性
auto: 一旦页面加载,则开始加载视频;
metadata: 当页面加载后仅加载视频的元数据(例如长度),建议使用 metadata以便视频自动获取第一帧作为封面 poster
none: 页面加载后不应加载视频
*/
preload: 'metadata',
showPlay: true,
/*
fit可选属性
none: 保存原有内容,不进行缩放;
fill: 不保持原有比例,内容拉伸填充整个内容容器;
contain: 保存原有比例,内容以包含方式缩放;
cover: 保存原有比例,内容以覆盖方式缩放
*/
fit: 'contain'
});
const veoRef = ref();
// 参考文档https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/video
const veoPoster = ref();
const originPlay = ref(true);
const hidden = ref(false); // 是否隐藏播放器中间的播放按钮
const emits = defineEmits(['play', 'pause']);
const veoWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`;
}
return props.width;
});
const veoHeight = computed(() => {
if (typeof props.height === 'number') {
return `${props.height}px`;
}
return props.height;
});
onMounted(() => {
if (props.autoplay) {
hidden.value = true;
originPlay.value = false;
}
/*
自定义设置播放速度,经测试:
在vue2中需设置this.$refs.veoRef.playbackRate = 2
在vue3中需设置veoRef.value.defaultPlaybackRate = 2
*/
// veoRef.value.defaultPlaybackRate = 2
});
/*
loadedmetadata 事件在元数据metadata被加载完成后触发
loadeddata 事件在媒体当前播放位置的视频帧(通常是第一帧)加载完成后触发
若在移动/平板设备的浏览器设置中开启了流量节省data-saver模式该事件则不会被触发。
preload 为 none 时不会触发
*/
function getPoster() {
// 在未设置封面时自动截取视频0.5s对应帧作为视频封面
// 由于不少视频第一帧为黑屏故设置视频开始播放时间为0.5s,即取该时刻帧作为封面图
veoRef.value.currentTime = props.second;
// 创建canvas元素
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// canvas画图
canvas.width = veoRef.value.videoWidth;
canvas.height = veoRef.value.videoHeight;
ctx?.drawImage(veoRef.value, 0, 0, canvas.width, canvas.height);
// 把canvas转成base64编码格式
veoPoster.value = canvas.toDataURL('image/png');
}
function onPlay() {
if (originPlay.value) {
veoRef.value.currentTime = 0;
originPlay.value = false;
}
if (props.autoplay) {
veoRef.value?.pause();
emits('pause');
} else {
hidden.value = true;
veoRef.value?.play();
emits('play');
}
}
function onPause() {
hidden.value = false;
}
function onPlaying() {
hidden.value = true;
}
</script>
<template>
<div class="m-video" :class="{ 'video-hover': !hidden }" :style="`width: ${veoWidth}; height: ${veoHeight};`">
<video
ref="veoRef"
class="u-video"
:style="`object-fit: ${fit};`"
:src="src"
:poster="poster ? poster : veoPoster"
:autoplay="autoplay"
:controls="!originPlay && controls"
:loop="loop"
:muted="autoplay || muted"
:preload="preload"
crossorigin="anonymous"
@loadedmetadata="poster ? () => false : getPoster()"
@pause="showPlay ? onPause() : () => false"
@playing="showPlay ? onPlaying() : () => false"
@click.prevent.once="onPlay"
v-bind="$attrs"
>
您的浏览器不支持video标签
</video>
<span v-show="originPlay || showPlay" class="icon-play" :class="{ 'icon-hidden': hidden }">
<svg class="play-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 34 34">
<path
d="M28.26,11.961L11.035,0.813C7.464-1.498,3,1.391,3,6.013v21.974c0,4.622,4.464,7.511,8.035,5.2L28.26,22.039
C31.913,19.675,31.913,14.325,28.26,11.961z"
></path>
</svg>
</span>
</div>
</template>
<style lang="less" scoped>
.m-video {
position: relative;
background: #000;
cursor: pointer;
.u-video {
display: inline-block;
width: 100%;
height: 100%;
vertical-align: bottom;
}
.icon-play {
display: inline-block;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
width: 80px;
height: 80px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.6);
pointer-events: none;
transition: background-color 0.3s;
.play-svg {
display: inline-block;
color: #fff;
fill: currentColor;
width: 29px;
height: 34px;
margin-top: 23px;
margin-left: 27px;
}
}
.icon-hidden {
opacity: 0;
}
}
.video-hover {
&:hover {
.icon-play {
background-color: rgba(0, 0, 0, 0.7);
}
}
}
</style>

View File

@@ -1,182 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import Spin from '../Spin/Spin.vue';
import { useResizeObserver } from '../Utils';
/*
宽度固定图片等比例缩放使用JS获取每张图片宽度和高度结合 `relative` 和 `absolute` 定位
计算每个图片的位置 `top``left`,保证每张新的图片都追加在当前高度最小的那列末尾
*/
interface Image {
name?: string // 图片名称
src: string // 图片地址
}
interface Props {
images?: Image[] // 图片数组
columnCount?: number // 要划分的列数
columnGap?: number // 各列之间的间隙,单位 px
width?: string | number // 瀑布流区域的总宽度,单位 px
borderRadius?: number // 瀑布流区域和图片圆角,单位 px
backgroundColor?: string // 瀑布流区域背景填充色
spinProps?: object // Spin 组件属性配置,参考 Spin Props用于配置图片加载中样式
}
const props = withDefaults(defineProps<Props>(), {
images: () => [],
columnCount: 3,
columnGap: 20,
width: '100%',
borderRadius: 8,
backgroundColor: '#F2F4F8',
spinProps: () => ({})
});
const waterfallRef = ref();
const waterfallWidth = ref<number>();
const loaded = ref(Array(props.images.length).fill(false)); // 图片是否加载完成
const imageWidth = ref<number>();
const imagesProperty = ref<{ width: number; height: number; top: number; left: number }[]>([]);
const preColumnHeight = ref<number[]>(Array(props.columnCount).fill(0)); // 每列的高度
const flag = ref(0);
const totalWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`;
} else {
return props.width;
}
});
const height = computed(() => {
return Math.max(...preColumnHeight.value) + props.columnGap;
});
const len = computed(() => {
return props.images.length;
});
watch(
() => [props.images, props.columnCount, props.columnGap, props.width],
() => {
waterfallWidth.value = waterfallRef.value.offsetWidth;
preColumnHeight.value = Array(props.columnCount).fill(0);
flag.value++;
preloadImages(flag.value);
},
{
deep: true, // 强制转成深层侦听器
flush: 'post' // 在侦听器回调中访问被 Vue 更新之后的 DOM
}
);
onMounted(() => {
waterfallWidth.value = waterfallRef.value.offsetWidth;
preloadImages(flag.value);
});
function updateWatefall() {
const currentWidth = waterfallRef.value.offsetWidth;
// 窗口宽度改变时重新计算瀑布流布局
if (props.images.length && currentWidth !== waterfallWidth.value) {
waterfallWidth.value = currentWidth;
flag.value++;
preloadImages(flag.value);
}
}
useResizeObserver(waterfallRef, updateWatefall);
async function preloadImages(symbol: number) {
// 计算图片宽高和位置topleft
// 计算每列的图片宽度
imageWidth.value = ((waterfallWidth.value as number) - (props.columnCount + 1) * props.columnGap) / props.columnCount;
imagesProperty.value.splice(0);
for (let i = 0; i < len.value; i++) {
if (symbol === flag.value) {
await loadImage(props.images[i].src, i);
} else {
return false;
}
}
}
function loadImage(url: string, n: number) {
return new Promise((resolve) => {
const image = new Image();
image.src = url;
image.onload = function () {
// 图片加载完成时执行此时可通过image.width和image.height获取到图片原始宽高
const height = image.height / (image.width / (imageWidth.value as number));
imagesProperty.value[n] = {
// 存储图片宽高和位置信息
width: imageWidth.value as number,
height: height,
...getPosition(n, height)
};
resolve('load');
};
});
}
function getPosition(i: number, height: number) {
// 获取图片位置信息topleft
if (i < props.columnCount) {
preColumnHeight.value[i] = props.columnGap + height;
return {
top: props.columnGap,
left: ((imageWidth.value as number) + props.columnGap) * i + props.columnGap
};
} else {
const top = Math.min(...preColumnHeight.value);
let index = 0;
for (let n = 0; n < props.columnCount; n++) {
if (preColumnHeight.value[n] === top) {
index = n;
break;
}
}
preColumnHeight.value[index] = top + props.columnGap + height;
return {
top: top + props.columnGap,
left: ((imageWidth.value as number) + props.columnGap) * index + props.columnGap
};
}
}
function onLoaded(index: number) {
loaded.value[index] = true;
}
function getImageName(image: Image) {
// 从图像地址src中获取图像名称
if (image) {
if (image.name) {
return image.name;
} else {
const res = image.src.split('?')[0].split('/');
return res[res.length - 1];
}
}
}
</script>
<template>
<div
ref="waterfallRef"
class="m-waterfall"
:style="`--border-radius: ${borderRadius}px; background-color: ${backgroundColor}; width: ${totalWidth}; height: ${height}px;`"
>
<Spin
class="waterfall-image"
:style="`width: ${property.width}px; height: ${property.height}px; top: ${property && property.top}px; left: ${property && property.left}px;`"
:spinning="!loaded[index]"
size="small"
indicator="dynamic-circle"
v-bind="spinProps"
v-for="(property, index) in imagesProperty"
:key="index"
>
<img class="u-image" :src="images[index].src" :alt="getImageName(images[index])" @load="onLoaded(index)" />
</Spin>
</div>
</template>
<style lang="less" scoped>
.m-waterfall {
position: relative;
border-radius: var(--border-radius);
.waterfall-image {
position: absolute;
.u-image {
width: 100%;
height: 100%;
border-radius: var(--border-radius);
display: inline-block;
vertical-align: bottom;
}
}
}
</style>

View File

@@ -116,7 +116,7 @@ const cardStyle = computed(() => ({
</script>
<style scoped lang="scss">
.header-logo-container {
min-width: 30%;
min-width: 20%;
display: flex;
align-items: center;

View File

@@ -1,6 +1,16 @@
<template>
<AFlex :vertical="false" align="center" justify="flex-end" class="header-menu-container">
<AFlex :vertical="false" align="center" justify="flex-start" class="header-menu-item" gap="large">
<!-- 存储选择 -->
<div class="header-select-container">
<ACascader v-model:value="uploadStore.storageSelected"
:options="configList"
:show-search="{ filter }"
:field-names="{ label: 'name', value: 'value', children: 'children' }"
placeholder="选择存储桶">
</ACascader>
</div>
<!-- 社区按钮 -->
<div class="button-wrapper">
<AButton type="text" shape="circle" size="large" class="header-menu-item-btn">
@@ -133,15 +143,37 @@ import accountSetting from "@/assets/svgs/setting.svg";
import logout from "@/assets/svgs/logout.svg";
import useStore from "@/store";
import ImageUpload from "@/views/Photograph/ImageUpload/ImageUpload.vue";
import {getStorageConfigListApi} from "@/api/storage";
import type {ShowSearchType} from 'ant-design-vue/es/cascader';
const uploadStore = useStore().upload;
const user = useStore().user;
const configList = ref<any[]>([]);
async function getUserConfigList() {
const res: any = await getStorageConfigListApi();
if (res && res.code === 200) {
configList.value = res.data.records;
}
}
const filter: ShowSearchType['filter'] = (inputValue, path) => {
return path.some(option => option.label.toLowerCase().indexOf(inputValue.toLowerCase()) > -1);
};
onMounted(() => {
getUserConfigList();
});
</script>
<style scoped lang="scss">
.header-menu-container {
width: 30%;
width: 40%;
display: flex;
align-items: center;
justify-content: space-between;
@@ -151,6 +183,13 @@ const user = useStore().user;
display: flex;
justify-content: flex-end;
.header-select-container {
display: flex;
flex-direction: row;
align-items: center;
//gap: 20px;
}
.header-menu-item-btn {
display: block;
}

View File

@@ -136,19 +136,12 @@ function scrollToSelectedMenuItem() {
}
onMounted(() => {
menu.currentMenu = route.path.replace('/main', '').split('/').slice(0, 3).join('/').substring(1);
scrollToSelectedMenuItem();
});
// watch(
// () => route.path,
// (newPath) => {
// if (!newPath.includes(menu.currentMenu)) {
// router.push(`/main/${menu.currentMenu}`);
// }
// scrollToSelectedMenuItem();
// }
// );
router.afterEach((_to) => {
menu.currentMenu = route.path.replace('/main', '').split('/').slice(0, 3).join('/').substring(1);
});
</script>
<style scoped lang="scss" src="./index.scss">

View File

@@ -1,4 +1,4 @@
import localforage from 'localforage';
// import localforage from 'localforage';
interface UploadPredictResult {
isAnime: boolean;
@@ -36,6 +36,8 @@ export const useUploadStore = defineStore(
thumb_size: null,
});
const storageSelected = ref<any[]>([]);
/**
* 打开上传抽屉
*/
@@ -65,6 +67,7 @@ export const useUploadStore = defineStore(
return {
openUploadDrawer,
predictResult,
storageSelected,
openUploadDrawerFn,
clearPredictResult,
};
@@ -72,10 +75,10 @@ export const useUploadStore = defineStore(
{
// 开启数据持久化
persistedState: {
persist: false,
storage: localforage,
persist: true,
storage: localStorage,
key: 'upload',
includePaths: []
includePaths: ["storageSelected"]
}
}
);

View File

@@ -49,18 +49,20 @@
import Vue3JustifiedLayout from "vue3-justified-layout";
import 'vue3-justified-layout/dist/style.css';
import {queryLocationDetailListApi} from "@/api/storage";
import useStore from "@/store";
const selected = ref<(string | number)[]>([]);
const albumList = ref<any[]>([]);
const route = useRoute();
const router = useRouter();
const upload = useStore().upload;
const options = reactive({
targetRowHeight: 200 // 高度
});
async function getImageList(id: number) {
const res: any = await queryLocationDetailListApi(id, "ali", "schisandra-album");
const res: any = await queryLocationDetailListApi(id, upload.storageSelected[0], upload.storageSelected[1]);
console.log(res);
if (res && res.code === 200) {
albumList.value = res.data.records;

View File

@@ -24,9 +24,11 @@
</template>
<script setup lang="ts">
import {queryLocationAlbumApi} from "@/api/storage";
import useStore from "@/store";
const route = useRoute();
const router = useRouter();
const upload = useStore().upload;
function handleClick(id: number) {
router.push({path: route.path + `/${id}`});
@@ -36,14 +38,13 @@ const locationAlbums = ref<any[]>([]);
async function getLocationAlbums(provider: string, bucket: string) {
const res: any = await queryLocationAlbumApi(provider, bucket);
console.log(res);
if (res && res.code === 200) {
locationAlbums.value = res.data.records;
}
}
onMounted(() => {
getLocationAlbums("ali", "schisandra-album");
getLocationAlbums(upload.storageSelected[0], upload.storageSelected[1]);
});
</script>
<style scoped lang="scss">

View File

@@ -57,6 +57,7 @@ import Vue3JustifiedLayout from "vue3-justified-layout";
import 'vue3-justified-layout/dist/style.css';
import {getFaceSamplesDetailList} from "@/api/storage";
import ImageToolbar from "@/views/Photograph/ImageToolbar/ImageToolbar.vue";
import useStore from "@/store";
const selected = ref<(string | number)[]>([]);
@@ -64,12 +65,14 @@ const albumList = ref<any[]>([]);
const route = useRoute();
const router = useRouter();
const upload = useStore().upload;
const options = reactive({
targetRowHeight: 200 // 高度
});
async function getAlbumList(id: number) {
const res: any = await getFaceSamplesDetailList(id, "ali", "schisandra-album");
const res: any = await getFaceSamplesDetailList(id, upload.storageSelected[0], upload.storageSelected[1]);
if (res && res.code === 200) {
albumList.value = res.data.records;
}

View File

@@ -79,7 +79,7 @@
size="small">
添加名字
</AButton>
<AInput ref="addNameInput" v-model:value="addNameInputValue" v-show="item.showInput"
<AInput v-model:value="addNameInputValue" v-show="item.showInput"
@blur="hideAddNameInput(index)" size="small"
:maxlength="10"
@click.stop
@@ -98,7 +98,7 @@
size="small">
{{ item.face_name }}
</AButton>
<AInput ref="addNameInput" v-model:value="addNameInputValue" autofocus v-show="item.showInput"
<AInput v-model:value="addNameInputValue" autofocus v-show="item.showInput"
@blur="hideAddNameInput(index)" size="small"
:maxlength="10"
:placeholder="item.face_name"
@@ -122,8 +122,8 @@
<script setup lang="ts">
import {getFaceSamplesList, modifyFaceSampleName, modifyFaceTypeBatch} from "@/api/storage";
const faceList = ref<any[]>([]);
const addNameInput = ref<any>(null);
const addNameInputValue = ref<string>('');
const selecetedKey = ref<string>('0');
const loading = ref<boolean>(false);

View File

@@ -83,6 +83,7 @@ import Vue3JustifiedLayout from "vue3-justified-layout";
import 'vue3-justified-layout/dist/style.css';
import {queryAlbumDetailListApi} from "@/api/storage";
import ImageToolbar from "@/views/Photograph/ImageToolbar/ImageToolbar.vue";
import useStore from "@/store";
const selected = ref<(string | number)[]>([]);
@@ -93,9 +94,11 @@ const router = useRouter();
const options = reactive({
targetRowHeight: 200 // 高度
});
const upload = useStore().upload;
async function getAlbumList(id: number) {
const res: any = await queryAlbumDetailListApi(id, "ali", "schisandra-album");
const res: any = await queryAlbumDetailListApi(id, upload.storageSelected[0], upload.storageSelected[1]);
if (res && res.code === 200) {
albumList.value = res.data.records;
}

View File

@@ -28,6 +28,7 @@
import {queryThingAlbumApi} from "@/api/storage";
import {getZhCategoryNameByEnName, getZhLabelNameByEnName} from "@/constant/coco_ssd_label_category.ts";
import useStore from "@/store";
const thingAlbumList = ref<any[]>([]);
@@ -41,6 +42,7 @@ async function getThingAlbumList(provider: string, bucket: string) {
const route = useRoute();
const router = useRouter();
const upload = useStore().upload;
/**
* 点击事件
@@ -51,7 +53,7 @@ function handleClick(id: string) {
}
onMounted(() => {
getThingAlbumList("ali", 'schisandra-album');
getThingAlbumList(upload.storageSelected[0], upload.storageSelected[1]);
});
</script>

View File

@@ -1,11 +1,657 @@
<script setup lang="ts">
<template>
<div class="image-share">
<div class="image-share-left">
<div class="image-share-left-top">
<div class="image-share-left-title">
<h3>数据概览</h3>
</div>
<div class="image-share-left-content">
<ACard class="image-share-left-content-item"
type="inner"
style="background: linear-gradient(102.74deg, rgb(66, 230, 171) -7.03%, rgb(103, 235, 187) 97.7%);">
<div class="image-share-left-item-content">
<span style="font-weight: bolder;font-size: 2.3vh">浏览次数</span>
<span style="font-weight: bolder;font-size: 5vh">1</span>
<p style="font-size: 2vh;color: hsla(0,0%,100%,.6);">今日浏览
<span style="font-weight: bolder;font-size: 2.8vh;color: #fff;">+0</span>
</p>
</div>
</ACard>
<ACard class="image-share-left-content-item"
type="inner"
style="background: linear-gradient(101.63deg, rgb(82, 138, 250) -12.83%, rgb(122, 167, 255) 100%);">
<div class="image-share-left-item-content">
<span style="font-weight: bolder;font-size: 2.3vh">浏览人数</span>
<span style="font-weight: bolder;font-size: 5vh">1</span>
<p style="font-size: 2vh;color: hsla(0,0%,100%,.6);">今日浏览人数
<span style="font-weight: bolder;font-size: 2.8vh;color: #fff;">+0</span>
</p>
</div>
</ACard>
<ACard class="image-share-left-content-item"
type="inner"
style="background: linear-gradient(102.99deg, rgb(126, 92, 255) 3.18%, rgb(162, 139, 255) 102.52%);">
<div class="image-share-left-item-content">
<span style="font-weight: bolder;font-size: 2.3vh">发布次数</span>
<span style="font-weight: bolder;font-size: 5vh">1</span>
<p style="font-size: 2vh;color: hsla(0,0%,100%,.6);">今日发布
<span style="font-weight: bolder;font-size: 2.8vh;color: #fff;">+0</span>
</p>
</div>
</ACard>
</div>
</div>
<div class="image-share-left-bottom">
<div class="image-share-left-bottom-title">
<h3>快传管理</h3>
<ARangePicker
:value="hackValue || value"
:disabled-date="disabledDate"
@change="onChange"
@openChange="onOpenChange"
@calendarChange="onCalendarChange"
:bordered="true"
style="border-radius: 20px"
/>
</div>
<div class="image-share-left-bottom-content">
<ACard style="width: 100%;height: 100%;" :bodyStyle="{padding: 0}">
<ATable :columns="columns" size="large" style="width: 100%;height: 100%;" :bordered="false">
</ATable>
</ACard>
</div>
</div>
</div>
<div class="image-share-right">
<div class="image-share-right-top">
<h3>照片快传</h3>
</div>
<div class="image-share-right-bottom">
<div class="image-share-right-bottom-content">
<div class="image-share-right-bottom-upload" ref="qrContainer" v-if="fileList.length<=0">
<AUploadDragger
name="file"
:multiple="true"
:showUploadList="false"
:beforeUpload="beforeUpload"
v-model:fileList="fileList"
class="image-share-right-upload"
>
<div class="image-share-right-upload-item">
<p class="ant-upload-drag-icon">
<ABadge :offset="[-15, 20]" :count="fileList.length">
<AAvatar shape="square" :size="folderIconSize" :src="folder"/>
</ABadge>
</p>
<p class="ant-upload-text" style="font-size: 2.6vh;font-weight: bolder">单击或拖动文件到此区域以上传</p>
<AButton type="primary" size="large" shape="round" style="width: 70%"> </AButton>
<div class="qr">
<AQrcode :bordered="false" color="rgba(126, 126, 135, 0.48)"
:size="qrcodeSize"
:value="`git.landaiqing.cneyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiNjI1MTEyMjE3MzQyMDIxIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTczOTg3ODIyOCwibmJmIjoxNzM5ODcxMDI4LCJpYXQiOjE3Mzk4NzEwMjh9.EUiZsVjhGqHx1V5o90S3W5li6nIqucxy9eEY9LWgqXY`"
:icon="phone"
:iconSize="iconSize"
:status="`active`"
/>
<span style="font-size: 2vh;color: #999999">手机扫码上传</span>
</div>
</div>
</AUploadDragger>
</div>
<div class="image-share-right-bottom-container" v-else>
<div class="image-share-right-bottom-container-header">
<AInput v-model:value="titleName" :bordered="false" size="large" placeholder="给快传起个标题"/>
<ADropdown placement="bottomLeft" :trigger="['click']">
<template #overlay>
<AMenu>
<AMenuItem key="1">
<AUpload
name="file"
:multiple="true"
:showUploadList="false"
:beforeUpload="beforeUpload"
v-model:fileList="fileList"
>
上传文件
</AUpload>
</AMenuItem>
<AMenuItem key="2">
<APopover placement="bottomLeft" trigger="hover">
<template #content>
<AQrcode :bordered="false" color="rgba(126, 126, 135, 0.48)"
:size="qrcodeSize"
:value="`git.landaiqing.cneyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiNjI1MTEyMjE3MzQyMDIxIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTczOTg3ODIyOCwibmJmIjoxNzM5ODcxMDI4LCJpYXQiOjE3Mzk4NzEwMjh9.EUiZsVjhGqHx1V5o90S3W5li6nIqucxy9eEY9LWgqXY`"
:icon="phone"
:iconSize="iconSize"
:status="`active`"
/>
</template>
手机上传
</APopover>
</AMenuItem>
</AMenu>
</template>
<AButton size="middle" shape="circle">
<template #icon>
<PlusOutlined/>
</template>
</AButton>
</ADropdown>
</div>
<div class="image-share-right-bottom-content-list">
<p style="font-size: 2.0vh;color: #999999;cursor: default">{{ fileList.length }}个文件
{{ calculateTotalSize(fileList) }}</p>
<div class="image-share-right-bottom-content-list-wrapper">
<div class="image-share-right-bottom-content-list-item" v-for="(item, index) in fileList" :key="index">
<div class="file-thumbnail">
<AImage :width="50" :height="50" :src="convertFileToUrl(item.originFileObj)">
<template #previewMask>
</template>
</AImage>
</div>
<div class="file-info">
<p style="font-size: 2.0vh;color: #333333;cursor: default;font-weight: bold">{{ item.name }}</p>
<p style="font-size: 1.5vh;color: #999999;cursor: default">{{
formatByteSize(item.size)
}}</p>
</div>
<div class="file-operation">
<AButton size="middle" shape="circle" type="text" @click="removeBase64Image(index)">
<template #icon>
<CloseOutlined/>
</template>
</AButton>
</div>
</div>
</div>
</div>
<ADivider/>
<div class="image-share-right-bottom-operation">
<div class="image-share-right-operation-select">
<div class="image-share-right-operation-item">
<span class="label-text">访问时效</span>
<ASelect style="width: 50%">
<ASelectOption value="1">1</ASelectOption>
<ASelectOption value="3">3</ASelectOption>
<ASelectOption value="7">7</ASelectOption>
<ASelectOption value="15">15</ASelectOption>
<ASelectOption value="30">30</ASelectOption>
<ASelectOption value="0">永久</ASelectOption>
</ASelect>
</div>
<div class="image-share-right-operation-item">
<span class="label-text">访问密码</span>
<AInputPassword style="width: 50%"></AInputPassword>
</div>
<div class="image-share-right-operation-item">
<span class="label-text">访问限制</span>
<AInputNumber style="width: 50%" :defaultValue="100" :min="1"></AInputNumber>
</div>
</div>
<div class="image-share-right-bottom-operation-btn">
<AButton type="primary" size="large" shape="default" style="width: 100%">开始上传</AButton>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {Dayjs} from 'dayjs';
import folder from "@/assets/svgs/folder.svg";
import {NSFWJS} from "nsfwjs";
import {initNSFWJs, predictNSFW} from "@/utils/tfjs/nsfw.ts";
import {message} from "ant-design-vue";
import i18n from "@/locales";
import phone from "@/assets/svgs/qr-phone.svg";
type RangeValue = [Dayjs, Dayjs];
const dates = ref<RangeValue>();
const value = ref<RangeValue>();
const hackValue = ref<RangeValue>();
const titleName = ref<string>("");
const qrContainer = ref<HTMLDivElement | null>(null);
const disabledDate = (current: Dayjs) => {
if (!dates.value || (dates.value as any).length === 0) {
return false;
}
const tooLate = dates.value[0] && current.diff(dates.value[0], 'days') > 30;
const tooEarly = dates.value[1] && dates.value[1].diff(current, 'days') > 30;
return tooEarly || tooLate;
};
const onOpenChange = (open: boolean) => {
if (open) {
dates.value = [] as any;
hackValue.value = [] as any;
} else {
hackValue.value = undefined;
}
};
const onChange = (val: RangeValue) => {
value.value = val;
};
const onCalendarChange = (val: RangeValue) => {
dates.value = val;
};
/**
* 格式化字节大小
* @param bytes
*/
function formatByteSize(bytes) {
if (bytes < 1024) {
return `${bytes} Bytes`;
} else if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(2)} KB`;
} else if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
} else {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
}
/**
* 格式化字节大小
* @param bytes
* @param decimals
*/
function formatBytes(bytes: number, decimals: number = 2): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
/**
* 计算文件总大小
* @param fileDataArray
*/
function calculateTotalSize(fileDataArray: { size: number }[]): string {
const totalSize = fileDataArray.reduce((acc, file) => acc + file.size, 0);
return formatBytes(totalSize);
}
const columns = [
{
title: '快传记录',
dataIndex: 'name',
key: 'name',
},
{
title: '上传时间',
dataIndex: 'created_at',
key: 'created_at',
},
{
title: '浏览次数',
dataIndex: 'views',
key: 'views',
},
{
title: '浏览人数',
key: 'viewers',
dataIndex: 'viewers',
},
{
title: '传输状态',
key: 'status',
dataIndex: 'status',
},
{
title: '操作',
key: 'action',
dataIndex: 'action',
},
];
const qrcodeSize = ref<number>(220);
const iconSize = ref<number>(30);
const folderIconSize = ref<number>(100);
/**
* 更新二维码大小
*/
const updateQrcodeSize = () => {
if (qrContainer.value) {
// 设置 QRCode 大小
const containerWidth = qrContainer.value.clientWidth;
qrcodeSize.value = containerWidth * 0.5; // 设置 QRCode 为父盒子宽度的80%
folderIconSize.value = containerWidth * 0.3; // 设置文件夹图标大小为父盒子宽度的10%
iconSize.value = Math.min(containerWidth * 0.1, 40); // 设置 icon 大小为父盒子宽度的10%
}
};
const fileList = ref<any[]>([]);
/**
* 上传文件前置
* @param file
*/
async function beforeUpload(file: any) {
if (!window.FileReader) return false; // 判断是否支持FileReader
const reader = new FileReader();
reader.readAsDataURL(file); // 文件转换
reader.onloadend = async function () {
const img: HTMLImageElement = document.createElement('img');
img.src = reader.result as string;
img.onload = async () => {
// 图片 NSFW 检测
const nsfw: NSFWJS = await initNSFWJs();
const isNSFW: boolean = await predictNSFW(nsfw, img);
if (isNSFW) {
message.error(i18n.global.t('comment.illegalImage'));
return false;
}
};
};
return true;
}
/**
* 删除 base64 图片
* @param index
*/
async function removeBase64Image(index: number) {
fileList.value.splice(index, 1);
}
/**
* 转换文件为 URL
* @param file
*/
function convertFileToUrl(file: any) {
return URL.createObjectURL(file);
}
onMounted(() => {
window.addEventListener("resize", updateQrcodeSize);
});
</script>
<template>
<style scoped lang="scss">
.image-share {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
height: 100%;
gap: 20px;
</template>
.image-share-left {
height: 100%;
width: 65%;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
.image-share-left-top {
width: 100%;
height: 30%;
display: flex;
flex-direction: column;
gap: 10px;
.image-share-left-title {
width: 100%;
height: 20%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.image-share-left-content {
width: 100%;
height: 80%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
.image-share-left-content-item {
height: 100%;
width: 30%;
color: #fff;
overflow: auto;
.image-share-left-item-content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
overflow: hidden;
}
}
}
}
.image-share-left-bottom {
width: 100%;
height: 70%;
display: flex;
flex-direction: column;
.image-share-left-bottom-title {
width: 100%;
height: 20%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.image-share-left-bottom-content {
width: 100%;
height: 80%;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
}
}
.image-share-right {
height: 100%;
width: 35%;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
.image-share-right-top {
width: 100%;
height: 6%;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
.image-share-right-bottom {
width: 100%;
height: 94%;
display: flex;
flex-direction: column;
.image-share-right-bottom-content {
width: 90%;
height: 100%;
padding: 20px;
background: #ffffff;
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: 10px;
.image-share-right-bottom-upload {
width: 100%;
height: 100%;
overflow: auto;
.image-share-right-upload {
width: 100%;
height: 100%;
}
}
.image-share-right-bottom-container {
width: 100%;
height: 100%;
.image-share-right-bottom-container-header {
width: 100%;
height: 10%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.image-share-right-bottom-content-list {
width: 95%;
height: 40%;
display: flex;
flex-direction: column;
align-content: flex-start;
justify-content: flex-start;
gap: 15px;
flex-wrap: nowrap;
padding: 10px;
overflow: auto;
background-color: #f5f5f5;
border-radius: 10px;
.image-share-right-bottom-content-list-wrapper {
width: 100%;
height: 27vh;
display: flex;
flex-direction: column;
align-content: flex-start;
justify-content: flex-start;
overflow: auto;
gap: 10px;
.image-share-right-bottom-content-list-item {
width: 100%;
height: 50px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
.file-thumbnail {
height: 100%;
width: 17%;
}
.file-info {
height: 100%;
width: 63%;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
}
.file-operation {
height: 100%;
width: 20%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
}
}
}
.image-share-right-bottom-operation {
width: 100%;
height: 40%;
display: flex;
flex-direction: column;
align-items: center;
.image-share-right-operation-select {
width: 100%;
height: 75%;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
flex-wrap: nowrap;
.image-share-right-operation-item {
width: 100%;
height: 5vh;
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: space-between;
.label-text {
width: 50%;
color: #999999;
font-size: 2.2vh;
}
}
}
.image-share-right-bottom-operation-btn {
width: 100%;
height: 35%;
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
}
}
}
.image-share-right-upload-item {
//width: 100% !important;
//height: 100% !important;
display: flex;
flex-direction: column;
align-items: center;
//justify-content: center;
gap: 2vh;
}
<style scoped lang="scss" src="./index.scss">
</style>

View File

@@ -26,7 +26,7 @@
<div v-for="(itemList, index) in images" :key="index">
<span style="margin-left: 10px;font-size: 13px">{{ itemList.date }}</span>
<AImagePreviewGroup>
<Vue3JustifiedLayout v-model:list="itemList.list" :options="options">
<Vue3JustifiedLayout v-model:list="itemList.list" :options="options" style="line-height: 0 !important;">
<template #default="{ item }">
<CheckCard :key="index"
class="photo-item"
@@ -41,11 +41,13 @@
:alt="item.file_name"
:key="index"
:height="200"
:previewMask="false"
:preview="{
src: item.url,
}"
loading="lazy"/>
loading="lazy">
<template #previewMask>
</template>
</AImage>
</CheckCard>
</template>
</Vue3JustifiedLayout>
@@ -76,7 +78,7 @@ import Vue3JustifiedLayout from "vue3-justified-layout";
import 'vue3-justified-layout/dist/style.css';
import ImageUpload from "@/views/Photograph/ImageUpload/ImageUpload.vue";
import useStore from "@/store";
import {getSingleImageApi, queryAllImagesApi} from "@/api/storage";
import {queryAllImagesApi} from "@/api/storage";
import ImageToolbar from "@/views/Photograph/ImageToolbar/ImageToolbar.vue";
@@ -94,36 +96,12 @@ const images = ref<any[]>([]);
* 获取所有图片
*/
async function getAllImages() {
const res: any = await queryAllImagesApi("image", false, "ali", "schisandra-album");
const res: any = await queryAllImagesApi("image", false, upload.storageSelected[0], upload.storageSelected[1]);
if (res && res.code === 200) {
images.value = res.data.records;
}
}
// const previewUrl = ref<string>("");
//
// /**
// * 获取单张图片
// * @param id
// */
// async function getSingleImage(id: number) {
// previewUrl.value = "";
// const res: any = await getSingleImageApi(id);
// if (res && res.code === 200) {
// previewUrl.value = res.data;
// setVisible(true);
// return;
// }
// previewUrl.value = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAADDCAYAAADQvc6UAAABRWlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGASSSwoyGFhYGDIzSspCnJ3UoiIjFJgf8LAwSDCIMogwMCcmFxc4BgQ4ANUwgCjUcG3awyMIPqyLsis7PPOq3QdDFcvjV3jOD1boQVTPQrgSkktTgbSf4A4LbmgqISBgTEFyFYuLykAsTuAbJEioKOA7DkgdjqEvQHEToKwj4DVhAQ5A9k3gGyB5IxEoBmML4BsnSQk8XQkNtReEOBxcfXxUQg1Mjc0dyHgXNJBSWpFCYh2zi+oLMpMzyhRcASGUqqCZ16yno6CkYGRAQMDKMwhqj/fAIcloxgHQqxAjIHBEugw5sUIsSQpBobtQPdLciLEVJYzMPBHMDBsayhILEqEO4DxG0txmrERhM29nYGBddr//5/DGRjYNRkY/l7////39v///y4Dmn+LgeHANwDrkl1AuO+pmgAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAwqADAAQAAAABAAAAwwAAAAD9b/HnAAAHlklEQVR4Ae3dP3PTWBSGcbGzM6GCKqlIBRV0dHRJFarQ0eUT8LH4BnRU0NHR0UEFVdIlFRV7TzRksomPY8uykTk/zewQfKw/9znv4yvJynLv4uLiV2dBoDiBf4qP3/ARuCRABEFAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghgg0Aj8i0JO4OzsrPv69Wv+hi2qPHr0qNvf39+iI97soRIh4f3z58/u7du3SXX7Xt7Z2enevHmzfQe+oSN2apSAPj09TSrb+XKI/f379+08+A0cNRE2ANkupk+ACNPvkSPcAAEibACyXUyfABGm3yNHuAECRNgAZLuYPgEirKlHu7u7XdyytGwHAd8jjNyng4OD7vnz51dbPT8/7z58+NB9+/bt6jU/TI+AGWHEnrx48eJ/EsSmHzx40L18+fLyzxF3ZVMjEyDCiEDjMYZZS5wiPXnyZFbJaxMhQIQRGzHvWR7XCyOCXsOmiDAi1HmPMMQjDpbpEiDCiL358eNHurW/5SnWdIBbXiDCiA38/Pnzrce2YyZ4//59F3ePLNMl4PbpiL2J0L979+7yDtHDhw8vtzzvdGnEXdvUigSIsCLAWavHp/+qM0BcXMd/q25n1vF57TYBp0a3mUzilePj4+7k5KSLb6gt6ydAhPUzXnoPR0dHl79WGTNCfBnn1uvSCJdegQhLI1vvCk+fPu2ePXt2tZOYEV6/fn31dz+shwAR1sP1cqvLntbEN9MxA9xcYjsxS1jWR4AIa2Ibzx0tc44fYX/16lV6NDFLXH+YL32jwiACRBiEbf5KcXoTIsQSpzXx4N28Ja4BQoK7rgXiydbHjx/P25TaQAJEGAguWy0+2Q8PD6/Ki4R8EVl+bzBOnZY95fq9rj9zAkTI2SxdidBHqG9+skdw43borCXO/ZcJdraPWdv22uIEiLA4q7nvvCug8WTqzQveOH26fodo7g6uFe/a17W3+nFBAkRYENRdb1vkkz1CH9cPsVy/jrhr27PqMYvENYNlHAIesRiBYwRy0V+8iXP8+/fvX11Mr7L7ECueb/r48eMqm7FuI2BGWDEG8cm+7G3NEOfmdcTQw4h9/55lhm7DekRYKQPZF2ArbXTAyu4kDYB2YxUzwg0gi/41ztHnfQG26HbGel/crVrm7tNY+/1btkOEAZ2M05r4FB7r9GbAIdxaZYrHdOsgJ/wCEQY0J74TmOKnbxxT9n3FgGGWWsVdowHtjt9Nnvf7yQM2aZU/TIAIAxrw6dOnAWtZZcoEnBpNuTuObWMEiLAx1HY0ZQJEmHJ3HNvGCBBhY6jtaMoEiJB0Z29vL6ls58vxPcO8/zfrdo5qvKO+d3Fx8Wu8zf1dW4p/cPzLly/dtv9Ts/EbcvGAHhHyfBIhZ6NSiIBTo0LNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiEC/wGgKKC4YMA4TAAAAABJRU5ErkJggg==";
// setVisible(true);
// }
//
// const visible = ref<boolean>(false);
//
// const setVisible = (value): void => {
// visible.value = value;
// };
onMounted(() => {
getAllImages();

View File

@@ -60,7 +60,7 @@ const props = defineProps({
width: calc(100% - 220px);
height: 70px;
top: 70px;
z-index: 3;
z-index: 4;
display: flex;
box-sizing: border-box;

View File

@@ -70,7 +70,7 @@ const progressStatus = ref<string>('active');
// 压缩图片配置
const options = {
maxSizeMB: 0.4,
maxSizeMB: 0.2,
maxWidthOrHeight: 750,
maxIteration: 2,
useWebWorker: true,
@@ -224,8 +224,8 @@ async function customUploadRequest(file: any) {
formData.append("thumbnail", binaryData);
}
formData.append("data", JSON.stringify({
provider: 'ali',
bucket: 'schisandra-album',
provider: upload.storageSelected[0],
bucket: upload.storageSelected[1],
fileType: file.file.type,
...upload.predictResult,
}));

View File

@@ -65,7 +65,6 @@ const options = reactive({
const getRecentImages = async () => {
const res: any = await queryRecentImagesApi();
console.log(res);
if (res && res.code === 200) {
images.value = res.data.records;
}

View File

@@ -1,8 +1,95 @@
<template>
<CommentReply/>
<div class="recycling-bin">
<div class="recycling-bin-header">
<AButton type="link" size="large" class="recycling-bin-title">回收站</AButton>
<span class="recycling-bin-desc">保存最近10天从云端删除的内容</span>
</div>
<div class="photo-list">
<div style="width:100%;height:100%;" v-if="images.length !== 0">
<div v-for="(itemList, index) in images" :key="index">
<span style="margin-left: 10px;font-size: 13px">{{ itemList.date }}</span>
<AImagePreviewGroup>
<Vue3JustifiedLayout v-model:list="itemList.list" :options="options">
<template #default="{ item }">
<CheckCard :key="index"
class="photo-item"
margin="0"
border-radius="0"
v-model="selected"
:showHoverCircle="true"
:iconSize="20"
:showSelectedEffect="true"
:value="item.id">
<AImage :src="item.url"
:alt="item.file_name"
:key="index"
style="height: 200px"
:previewMask="false"
loading="lazy"/>
</CheckCard>
</template>
</Vue3JustifiedLayout>
</AImagePreviewGroup>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Vue3JustifiedLayout from "vue3-justified-layout";
import 'vue3-justified-layout/dist/style.css';
const selected = ref<(string | number)[]>([]);
const images = ref<any[]>([]);
const options = reactive({
targetRowHeight: 200 // 高度
});
</script>
<style scoped lang="scss" src="./index.scss">
<style scoped lang="scss">
.recycling-bin {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
width: 100%;
height: 100%;
.recycling-bin-header {
width: 100%;
height: 45px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 10px;
border-bottom: 1px solid #e2e2e2;
.recycling-bin-title {
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: bold;
color: #333;
}
.recycling-bin-desc {
font-size: 12px;
color: #333;
}
}
.photo-list {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
height: calc(100% - 65px);
}
}
</style>