消息
This commit is contained in:
147
TUIKit/components/common/Avatar/index.vue
Normal file
147
TUIKit/components/common/Avatar/index.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div
|
||||
class="avatar-container"
|
||||
:style="{
|
||||
width: avatarSize,
|
||||
height: avatarSize,
|
||||
borderRadius: avatarBorderRadius,
|
||||
}"
|
||||
>
|
||||
<template v-if="isUniFrameWork">
|
||||
<image
|
||||
v-if="!loadErrorInUniapp"
|
||||
class="avatar-image"
|
||||
:src="avatarImageUrl || defaultAvatarUrl"
|
||||
@load="avatarLoadSuccess"
|
||||
@error="avatarLoadFailed"
|
||||
/>
|
||||
<image
|
||||
v-else
|
||||
class="avatar-image"
|
||||
:src="defaultAvatarUrl"
|
||||
@load="avatarLoadSuccess"
|
||||
@error="avatarLoadFailed"
|
||||
/>
|
||||
</template>
|
||||
<img
|
||||
v-else
|
||||
class="avatar-image"
|
||||
:src="avatarImageUrl || defaultAvatarUrl"
|
||||
@load="avatarLoadSuccess"
|
||||
@error="avatarLoadFailed"
|
||||
>
|
||||
<div
|
||||
v-if="useAvatarSkeletonAnimation && !isImgLoaded"
|
||||
:class="{
|
||||
placeholder: true,
|
||||
hidden: isImgLoaded,
|
||||
'skeleton-animation': useAvatarSkeletonAnimation
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, toRefs } from '../../../adapter-vue';
|
||||
import { isUniFrameWork } from '../../../utils/env';
|
||||
|
||||
interface IProps {
|
||||
url: string;
|
||||
size?: string;
|
||||
borderRadius?: string;
|
||||
useSkeletonAnimation?: boolean;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(key: 'onLoad', e: Event): void;
|
||||
(key: 'onError', e: Event): void;
|
||||
}
|
||||
|
||||
const defaultAvatarUrl = ref('https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_21.png');
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
// uniapp vue2 does not support constants in defineProps
|
||||
url: 'https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_21.png',
|
||||
size: '36px',
|
||||
borderRadius: '5px',
|
||||
useSkeletonAnimation: false,
|
||||
});
|
||||
|
||||
const {
|
||||
size: avatarSize,
|
||||
url: avatarImageUrl,
|
||||
borderRadius: avatarBorderRadius,
|
||||
useSkeletonAnimation: useAvatarSkeletonAnimation,
|
||||
} = toRefs(props);
|
||||
|
||||
let reloadAvatarTime = 0;
|
||||
const isImgLoaded = ref<boolean>(false);
|
||||
const loadErrorInUniapp = ref<boolean>(false);
|
||||
|
||||
function avatarLoadSuccess(e: Event) {
|
||||
isImgLoaded.value = true;
|
||||
emits('onLoad', e);
|
||||
}
|
||||
|
||||
function avatarLoadFailed(e: Event) {
|
||||
reloadAvatarTime += 1;
|
||||
if (reloadAvatarTime > 3) {
|
||||
return;
|
||||
}
|
||||
if (isUniFrameWork) {
|
||||
loadErrorInUniapp.value = true;
|
||||
} else {
|
||||
(e.currentTarget as HTMLImageElement).src = defaultAvatarUrl.value;
|
||||
}
|
||||
emits('onError', e);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #ececec;
|
||||
transition:
|
||||
opacity 0.3s,
|
||||
background-color 0.1s ease-out;
|
||||
|
||||
&.skeleton-animation {
|
||||
animation: breath 2s linear 0.3s infinite;
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breath {
|
||||
50% {
|
||||
/* stylelint-disable-next-line scss/no-global-function-names */
|
||||
background-color: darken(#ececec, 10%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
TUIKit/components/common/BottomPopup/index.ts
Normal file
3
TUIKit/components/common/BottomPopup/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import BottomPopup from './index.vue';
|
||||
|
||||
export default BottomPopup;
|
||||
159
TUIKit/components/common/BottomPopup/index.vue
Normal file
159
TUIKit/components/common/BottomPopup/index.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<!--
|
||||
移动端 底部弹出对话框 组件
|
||||
- pc 端,仅展示【插槽】内容,无弹出对话框,无对话框相关 header footer
|
||||
- mobile 端,底部弹出对话框,支持 对话框相关 header footer 定制展示,详情请参见参数列表
|
||||
-->
|
||||
<template>
|
||||
<div v-if="props.show">
|
||||
<div
|
||||
v-if="!isPC"
|
||||
:class="[
|
||||
'bottom-popup',
|
||||
!isPC && 'bottom-popup-h5',
|
||||
!isPC && props.modal && 'bottom-popup-modal',
|
||||
isUniFrameWork && 'bottom-popup-uni',
|
||||
]"
|
||||
@click="closeBottomPopup"
|
||||
>
|
||||
<div
|
||||
ref="dialogRef"
|
||||
:class="['bottom-popup-main', !isPC && 'bottom-popup-h5-main']"
|
||||
:style="{
|
||||
height: props.height,
|
||||
borderTopLeftRadius: props.borderRadius,
|
||||
borderTopRightRadius: props.borderRadius,
|
||||
}"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
v-if="title || showHeaderCloseButton"
|
||||
class="header"
|
||||
>
|
||||
<div
|
||||
v-if="title"
|
||||
class="header-title"
|
||||
>
|
||||
{{ title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="showHeaderCloseButton"
|
||||
class="header-close"
|
||||
@click="closeBottomPopup"
|
||||
>
|
||||
{{ TUITranslateService.t("关闭") }}
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
<div
|
||||
v-if="showFooterSubmitButton"
|
||||
class="footer"
|
||||
>
|
||||
<div
|
||||
class="footer-submit"
|
||||
@click="submit"
|
||||
>
|
||||
{{ submitButtonContent }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<slot v-else />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from '../../../adapter-vue';
|
||||
import { TUITranslateService } from '@tencentcloud/chat-uikit-engine';
|
||||
import { outsideClick } from '@tencentcloud/universal-api';
|
||||
import { isPC, isH5, isUniFrameWork } from '../../../utils/env';
|
||||
|
||||
const props = defineProps({
|
||||
// Whether to display the bottom pop-up dialog box
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Whether a mask layer is required, the default is true
|
||||
modal: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// Popup box content area height (excluding mask), default is fit-content
|
||||
height: {
|
||||
type: String,
|
||||
default: 'fit-content',
|
||||
},
|
||||
// Whether the pop-up dialog box can be closed by clicking outside, the default is true
|
||||
// uniapp only supports closing the pop-up dialog box by clicking the mask
|
||||
closeByClickOutside: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// The rounded angle of the top border corners is 0px by default, i.e. right angle by default
|
||||
borderRadius: {
|
||||
type: String,
|
||||
default: '0px',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// Whether to display the top close button, not displayed by default
|
||||
showHeaderCloseButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Whether to display the submit button at the bottom, not displayed by default
|
||||
showFooterSubmitButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Bottom submit button text, only valid when showFooterSubmitButton is true
|
||||
submitButtonContent: {
|
||||
type: String,
|
||||
default: () => TUITranslateService.t('确定'),
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['onOpen', 'onClose', 'onSubmit']);
|
||||
const dialogRef = ref();
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal: boolean, oldVal: boolean) => {
|
||||
if (newVal === oldVal) {
|
||||
return;
|
||||
}
|
||||
switch (newVal) {
|
||||
case true:
|
||||
emits('onOpen', dialogRef);
|
||||
nextTick(() => {
|
||||
// Effective under web h5
|
||||
if (isH5 && !isUniFrameWork) {
|
||||
if (props.closeByClickOutside) {
|
||||
outsideClick.listen({
|
||||
domRefs: dialogRef.value,
|
||||
handler: closeBottomPopup,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
break;
|
||||
case false:
|
||||
emits('onClose', dialogRef);
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const closeBottomPopup = () => {
|
||||
if (isUniFrameWork || isH5) {
|
||||
emits('onClose', dialogRef);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
emits('onSubmit');
|
||||
closeBottomPopup();
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss" src="./style/index.scss"></style>
|
||||
60
TUIKit/components/common/BottomPopup/style/h5.scss
Normal file
60
TUIKit/components/common/BottomPopup/style/h5.scss
Normal file
@@ -0,0 +1,60 @@
|
||||
.bottom-popup-h5 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: stretch;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
z-index: 10;
|
||||
border-radius: 5px 5px 0 0;
|
||||
|
||||
&-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
height: fit-content;
|
||||
background-color: #fff;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 20px;
|
||||
font-size: 16px;
|
||||
|
||||
.header-close {
|
||||
font-family: PingFangSC-Regular;
|
||||
font-weight: 400;
|
||||
color: #006eff;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 20px;
|
||||
|
||||
.footer-submit {
|
||||
color: #fff;
|
||||
padding: 12px 0;
|
||||
width: 100%;
|
||||
background: #006eff;
|
||||
text-align: center;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-popup-uni {
|
||||
padding-bottom: var(--window-bottom);
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
3
TUIKit/components/common/BottomPopup/style/index.scss
Normal file
3
TUIKit/components/common/BottomPopup/style/index.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
@import "../../../../assets/styles/common";
|
||||
@import "./h5";
|
||||
@import "./modal";
|
||||
3
TUIKit/components/common/BottomPopup/style/modal.scss
Normal file
3
TUIKit/components/common/BottomPopup/style/modal.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.bottom-popup-modal {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
308
TUIKit/components/common/DatePicker/date-picker-panel.vue
Normal file
308
TUIKit/components/common/DatePicker/date-picker-panel.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[n('')]"
|
||||
@mouseup.stop
|
||||
>
|
||||
<div :class="[n('body')]">
|
||||
<div :class="[n('body-header')]">
|
||||
<div :class="[n('body-header-prev')]">
|
||||
<div
|
||||
v-if="canYearLess"
|
||||
:class="[n('icon')]"
|
||||
@click="change('year', -1)"
|
||||
>
|
||||
<Icon
|
||||
:file="dLeftArrowIcon"
|
||||
:width="'12px'"
|
||||
:height="'12px'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="canMonthLess"
|
||||
:class="[n('icon')]"
|
||||
@click="change('month', -1)"
|
||||
>
|
||||
<Icon
|
||||
:file="leftArrowIcon"
|
||||
:width="'10px'"
|
||||
:height="'10px'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="[n('body-header-label')]">
|
||||
<div :class="[n('body-header-label-item')]">
|
||||
{{ year }}
|
||||
</div>
|
||||
<div :class="[n('body-header-label-item')]">
|
||||
{{ TUITranslateService.t(`time.${month}`) }}
|
||||
</div>
|
||||
</div>
|
||||
<div :class="[n('body-header-next')]">
|
||||
<div
|
||||
v-if="canMonthMore"
|
||||
:class="[n('icon')]"
|
||||
@click="change('month', 1)"
|
||||
>
|
||||
<Icon
|
||||
:file="rightArrowIcon"
|
||||
:width="'10px'"
|
||||
:height="'10px'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="canYearMore"
|
||||
:class="[n('icon')]"
|
||||
@click="change('year', 1)"
|
||||
>
|
||||
<Icon
|
||||
:file="dRightArrowIcon"
|
||||
:width="'12px'"
|
||||
:height="'12px'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="[n('body-content')]">
|
||||
<DateTable
|
||||
:type="props.type"
|
||||
:date="props.date"
|
||||
:startDate="props.startDate"
|
||||
:endDate="props.endDate"
|
||||
:currentPanelDate="currentPanelDate"
|
||||
@pick="handlePick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, onBeforeMount } from '../../../adapter-vue';
|
||||
import dayjs, { Dayjs, ManipulateType } from 'dayjs';
|
||||
import { TUITranslateService } from '@tencentcloud/chat-uikit-engine';
|
||||
import { DateCell } from './date-picker';
|
||||
import DateTable from './date-table.vue';
|
||||
import Icon from '../Icon.vue';
|
||||
import leftArrowIcon from '../../../assets/icon/left-arrow.svg';
|
||||
import rightArrowIcon from '../../../assets/icon/right-arrow.svg';
|
||||
import dLeftArrowIcon from '../../../assets/icon/d-left-arrow.svg';
|
||||
import dRightArrowIcon from '../../../assets/icon/d-right-arrow.svg';
|
||||
import { isPC } from '../../../utils/env';
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'range', // "single"/"range"
|
||||
},
|
||||
// Unique attribute when type is single
|
||||
date: {
|
||||
type: Dayjs,
|
||||
default: () => dayjs(),
|
||||
},
|
||||
// Unique attribute when type is range
|
||||
startDate: {
|
||||
type: Dayjs,
|
||||
default: null,
|
||||
},
|
||||
endDate: {
|
||||
type: Dayjs,
|
||||
default: null,
|
||||
},
|
||||
rangeType: {
|
||||
type: String,
|
||||
default: '', // "left"/"right"
|
||||
},
|
||||
currentOtherPanelValue: {
|
||||
type: Dayjs,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['pick', 'change']);
|
||||
const n = (className: string) => {
|
||||
return className
|
||||
? [
|
||||
'tui-date-picker-panel-' + className,
|
||||
!isPC && 'tui-date-picker-panel-h5-' + className,
|
||||
]
|
||||
: ['tui-date-picker-panel', !isPC && 'tui-date-picker-panel-h5'];
|
||||
};
|
||||
|
||||
const currentPanelDate = ref<typeof Dayjs>();
|
||||
const year = computed(() => currentPanelDate.value?.get('year'));
|
||||
const month = computed(() => currentPanelDate.value?.format('MMMM'));
|
||||
const canYearMore = computed(() => {
|
||||
const prevYearNumber = props.currentOtherPanelValue?.year() - 1;
|
||||
const prevYear = props.currentOtherPanelValue?.year(prevYearNumber);
|
||||
return (
|
||||
props.rangeType === 'right'
|
||||
|| currentPanelDate.value?.isBefore(prevYear, 'year')
|
||||
);
|
||||
});
|
||||
const canMonthMore = computed(() => {
|
||||
const prevMonthNumber = props.currentOtherPanelValue?.month() - 1;
|
||||
const prevMonth = props.currentOtherPanelValue?.month(prevMonthNumber);
|
||||
return (
|
||||
props.rangeType === 'right'
|
||||
|| currentPanelDate.value?.isBefore(prevMonth, 'month')
|
||||
);
|
||||
});
|
||||
const canYearLess = computed(() => {
|
||||
const nextYearNumber = props.currentOtherPanelValue?.year() + 1;
|
||||
const nextYear = props.currentOtherPanelValue?.year(nextYearNumber);
|
||||
return (
|
||||
props.rangeType === 'left'
|
||||
|| currentPanelDate.value?.isAfter(nextYear, 'year')
|
||||
);
|
||||
});
|
||||
const canMonthLess = computed(() => {
|
||||
const nextMonthNumber = props.currentOtherPanelValue?.month() + 1;
|
||||
const nextMonth = props.currentOtherPanelValue?.month(nextMonthNumber);
|
||||
return (
|
||||
props.rangeType === 'left'
|
||||
|| currentPanelDate.value?.isAfter(nextMonth, 'month')
|
||||
);
|
||||
});
|
||||
|
||||
// Range judgment:
|
||||
// Premise: If there is only one, it must be the start.
|
||||
// If there is a startDate:
|
||||
// When the left side of the interface first displays the month/year of the startDate.
|
||||
// If there is both a startDate and an endDate:
|
||||
// If they are in the same month:
|
||||
// Both are displayed on the left, and the next month is displayed on the right.
|
||||
// If they are not in the same month:
|
||||
// The start is displayed on the left, and the end is displayed on the right.
|
||||
// That is, to determine whether the start and end are in the same month.
|
||||
// If neither is present, the left displays the current month, and the right displays the next month.
|
||||
const handleSingleDate = (): { date: typeof Dayjs } => {
|
||||
if (props.date && dayjs(props.date)?.isValid()) {
|
||||
// props.date year and month
|
||||
return {
|
||||
date: props?.date,
|
||||
};
|
||||
}
|
||||
// nowadays year and month
|
||||
return {
|
||||
date: dayjs(),
|
||||
};
|
||||
};
|
||||
|
||||
const handleRangeDate = (): { date: typeof Dayjs } => {
|
||||
switch (props.rangeType) {
|
||||
case 'left':
|
||||
if (props.startDate && dayjs.isDayjs(props.startDate)) {
|
||||
return {
|
||||
date: props?.startDate,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
date: dayjs(),
|
||||
};
|
||||
}
|
||||
case 'right':
|
||||
if (
|
||||
props.endDate
|
||||
&& dayjs.isDayjs(props.endDate)
|
||||
&& props?.endDate?.isAfter(props.startDate, 'month')
|
||||
) {
|
||||
return {
|
||||
date: props?.endDate,
|
||||
};
|
||||
} else {
|
||||
const _month = (props.startDate || dayjs()).month();
|
||||
return {
|
||||
date: (props.startDate || dayjs()).month(_month + 1),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
date: dayjs(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function handlePick(cell: DateCell) {
|
||||
emit('pick', cell);
|
||||
}
|
||||
|
||||
function change(type: typeof ManipulateType, num: number) {
|
||||
currentPanelDate.value = dayjs(currentPanelDate.value.toDate()).add(
|
||||
num,
|
||||
type,
|
||||
);
|
||||
emit('change', currentPanelDate.value);
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
switch (props.type) {
|
||||
case 'single':
|
||||
currentPanelDate.value = handleSingleDate().date;
|
||||
emit('change', currentPanelDate.value);
|
||||
break;
|
||||
case 'range':
|
||||
currentPanelDate.value = handleRangeDate().date;
|
||||
emit('change', currentPanelDate.value);
|
||||
break;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tui-date-picker-panel {
|
||||
width: 200px;
|
||||
margin: 5px;
|
||||
|
||||
&-body {
|
||||
width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 30px;
|
||||
padding: 0 5px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&-prev {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
cursor: pointer;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
&-label {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
color: #666;
|
||||
|
||||
&-item {
|
||||
padding: 0 5px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
&-next {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
cursor: pointer;
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
19
TUIKit/components/common/DatePicker/date-picker.ts
Normal file
19
TUIKit/components/common/DatePicker/date-picker.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Dayjs } from 'dayjs';
|
||||
|
||||
export type DateCellType =
|
||||
| 'normal'
|
||||
| 'today'
|
||||
| 'week'
|
||||
| 'next-month'
|
||||
| 'prev-month';
|
||||
export interface DateCell {
|
||||
text?: number;
|
||||
disabled?: boolean;
|
||||
isSelected?: boolean;
|
||||
isSelectedStart?: boolean;
|
||||
isSelectedEnd?: boolean;
|
||||
isInRange?: boolean;
|
||||
isCurrent?: boolean;
|
||||
date: typeof Dayjs;
|
||||
type?: DateCellType;
|
||||
}
|
||||
321
TUIKit/components/common/DatePicker/date-table.vue
Normal file
321
TUIKit/components/common/DatePicker/date-table.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<table
|
||||
:class="['tui-date-table', !isPC && 'tui-date-table-h5']"
|
||||
cellspacing="0"
|
||||
cellpadding="0"
|
||||
role="grid"
|
||||
>
|
||||
<tbody class="tui-date-table-body">
|
||||
<tr class="tui-date-table-body-weeks">
|
||||
<th
|
||||
v-for="item in WEEKS"
|
||||
:key="item"
|
||||
class="tui-date-table-body-weeks-item"
|
||||
:aria-label="item + ''"
|
||||
scope="col"
|
||||
>
|
||||
{{ TUITranslateService.t(`time.${item}`) }}
|
||||
</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(row, rowKey) in rows"
|
||||
:key="rowKey"
|
||||
class="tui-date-table-body-days"
|
||||
>
|
||||
<td
|
||||
v-for="(col, colKey) in row"
|
||||
:key="colKey"
|
||||
:class="['tui-date-table-body-days-item', col.type]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'tui-date-table-body-days-item-cell',
|
||||
col.isSelected && 'selected',
|
||||
col.isSelectedStart && 'selected-start',
|
||||
col.isSelectedEnd && 'selected-end',
|
||||
col.isInRange && 'range',
|
||||
]"
|
||||
@click="handlePick(col)"
|
||||
>
|
||||
<span class="tui-date-table-body-days-item-cell-text">
|
||||
{{ col.text }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
computed,
|
||||
ref,
|
||||
getCurrentInstance,
|
||||
nextTick,
|
||||
watch,
|
||||
} from '../../../adapter-vue';
|
||||
import { TUITranslateService } from '@tencentcloud/chat-uikit-engine';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { DateCell, DateCellType } from './date-picker';
|
||||
import { isPC } from '../../../utils/env';
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'range', // "single"/"range"
|
||||
},
|
||||
currentPanelDate: {
|
||||
type: Dayjs,
|
||||
default: () => dayjs(),
|
||||
},
|
||||
// Unique attribute when type is single
|
||||
date: {
|
||||
type: Dayjs,
|
||||
default: null,
|
||||
},
|
||||
// Unique attribute when type is range
|
||||
startDate: {
|
||||
type: Dayjs,
|
||||
default: null,
|
||||
},
|
||||
endDate: {
|
||||
type: Dayjs,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['pick']);
|
||||
// vue instance
|
||||
const instance = getCurrentInstance();
|
||||
|
||||
const tableRows = ref<DateCell[][]>([[], [], [], [], [], []]);
|
||||
const currentPanelDateObject = ref<typeof Dayjs>(
|
||||
dayjs(props.currentPanelDate || null),
|
||||
);
|
||||
const dateObject = ref<typeof Dayjs>(dayjs(props.date || null));
|
||||
const startDateObject = ref<typeof Dayjs>(dayjs(props.startDate || null));
|
||||
const endDateObject = ref<typeof Dayjs>(dayjs(props.endDate || null));
|
||||
|
||||
const WEEKS_CONSTANT = computed(() => {
|
||||
return dayjs.weekdaysShort();
|
||||
});
|
||||
|
||||
const WEEKS = computed(() =>
|
||||
WEEKS_CONSTANT.value.map((w: string) => w.substring(1)),
|
||||
);
|
||||
|
||||
const startDateOnTable = computed(() => {
|
||||
const startDayOfMonth = currentPanelDateObject.value?.startOf('month');
|
||||
return startDayOfMonth?.subtract(startDayOfMonth?.day() || 7, 'day');
|
||||
});
|
||||
|
||||
// Table data
|
||||
const rows = computed(() => {
|
||||
const rows_ = tableRows.value;
|
||||
const cols = WEEKS.value.length;
|
||||
|
||||
const startOfMonth = currentPanelDateObject.value?.startOf('month');
|
||||
const startOfMonthDay = startOfMonth?.day() || 7; // day of this month first day
|
||||
const dateCountOfMonth = startOfMonth?.daysInMonth(); // total days of this month
|
||||
|
||||
let count = 1;
|
||||
for (let row = 0; row < tableRows.value.length; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const cellDate = startDateOnTable.value?.add(count, 'day');
|
||||
const text = cellDate?.date();
|
||||
// For type === "single", select the entered date
|
||||
// For type === "range", select the entered start and end dates
|
||||
const isSelected
|
||||
= props.type === 'single'
|
||||
&& cellDate?.format('YYYY-MM-DD')
|
||||
=== dateObject.value?.format('YYYY-MM-DD');
|
||||
const isSelectedStart
|
||||
= props.type === 'range'
|
||||
&& cellDate?.format('YYYY-MM-DD')
|
||||
=== startDateObject.value?.format('YYYY-MM-DD');
|
||||
const isSelectedEnd
|
||||
= props.type === 'range'
|
||||
&& cellDate?.format('YYYY-MM-DD')
|
||||
=== endDateObject.value?.format('YYYY-MM-DD');
|
||||
// For type === "range", check if it is within the selected range.
|
||||
const isInRange
|
||||
= cellDate?.isSameOrBefore(endDateObject.value, 'day')
|
||||
&& cellDate?.isSameOrAfter(startDateObject.value, 'day');
|
||||
let type: DateCellType = 'normal';
|
||||
if (count < startOfMonthDay) {
|
||||
// Prev month's date
|
||||
type = 'prev-month';
|
||||
} else if (count - startOfMonthDay >= dateCountOfMonth) {
|
||||
// Next month's date
|
||||
type = 'next-month';
|
||||
}
|
||||
rows_[row][col] = {
|
||||
type,
|
||||
date: cellDate,
|
||||
text,
|
||||
isSelected: isSelected || isSelectedStart || isSelectedEnd,
|
||||
isSelectedStart,
|
||||
isSelectedEnd,
|
||||
isInRange,
|
||||
};
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return rows_;
|
||||
});
|
||||
|
||||
const handlePick = (cell: DateCell) => {
|
||||
if (cell?.type !== 'normal') {
|
||||
return;
|
||||
}
|
||||
emit('pick', cell);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.currentPanelDate, props.date, props.startDate, props.endDate],
|
||||
() => {
|
||||
currentPanelDateObject.value = dayjs(props.currentPanelDate || null);
|
||||
dateObject.value = dayjs(props.date || null);
|
||||
startDateObject.value = dayjs(props.startDate || null);
|
||||
endDateObject.value = dayjs(props.endDate || null);
|
||||
nextTick(() => {
|
||||
instance?.proxy?.$forceUpdate();
|
||||
});
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* stylelint-disable selector-class-pattern */
|
||||
.tui-date-table {
|
||||
border-spacing: 0;
|
||||
-webkit-border-horizontal-spacing: 0;
|
||||
-webkit-border-vertical-spacing: 0;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&::after,
|
||||
&::before {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&-body {
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
|
||||
&-weeks,
|
||||
&-days {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&-weeks {
|
||||
width: 100%;
|
||||
|
||||
&-item {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
font-weight: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
&-days {
|
||||
color: #000;
|
||||
|
||||
&-item {
|
||||
&-cell {
|
||||
text-align: center;
|
||||
padding: 2px;
|
||||
margin: 2px 0;
|
||||
|
||||
&-text {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
border-radius: 12px;
|
||||
|
||||
.tui-date-table-body-days-item-cell-text {
|
||||
box-sizing: border-box;
|
||||
color: #007aff;
|
||||
border: 1px solid #007aff;
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.range {
|
||||
background-color: #007aff33;
|
||||
}
|
||||
|
||||
.selected-start {
|
||||
border-radius: 12px 0 0 12px;
|
||||
}
|
||||
|
||||
.selected-end {
|
||||
border-radius: 0 12px 12px 0;
|
||||
}
|
||||
|
||||
.selected-start.selected-end {
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.prev-month,
|
||||
.next-month {
|
||||
color: #666;
|
||||
background-color: #fff;
|
||||
|
||||
.range {
|
||||
color: #666;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.selected {
|
||||
.tui-date-table-body-days-item-cell-text {
|
||||
box-sizing: border-box;
|
||||
color: #666;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tui-date-table-h5 {
|
||||
|
||||
/* stylelint-disable-next-line no-descending-specificity */
|
||||
.tui-date-table-body-days-item-cell-text {
|
||||
cursor: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
td,
|
||||
._td,
|
||||
.tui-date-table-body-days-item {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
2
TUIKit/components/common/DatePicker/index.ts
Normal file
2
TUIKit/components/common/DatePicker/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import DatePicker from './index.vue';
|
||||
export default DatePicker;
|
||||
270
TUIKit/components/common/DatePicker/index.vue
Normal file
270
TUIKit/components/common/DatePicker/index.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<div :class="[n([''])]">
|
||||
<div
|
||||
:class="[n(['input']), isDatePanelShow && n(['input-active'])]"
|
||||
@click="setDatePanelDisplay(!isDatePanelShow)"
|
||||
>
|
||||
<slot name="start-icon" />
|
||||
<input
|
||||
v-model="startFormatDate"
|
||||
:placeholder="startPlaceholderVal"
|
||||
:class="[n(['input-start'])]"
|
||||
style="pointer-events: none"
|
||||
type="text"
|
||||
:readonly="true"
|
||||
:disabled="isUniFrameWork"
|
||||
autocomplete="false"
|
||||
>
|
||||
<span v-if="type !== 'single'">-</span>
|
||||
<input
|
||||
v-if="type !== 'single'"
|
||||
v-model="endFormatDate"
|
||||
:placeholder="endPlaceholderVal"
|
||||
:class="[n(['input-end'])]"
|
||||
style="pointer-events: none"
|
||||
type="text"
|
||||
:readonly="true"
|
||||
:disabled="isUniFrameWork"
|
||||
autocomplete="false"
|
||||
>
|
||||
<slot name="end-icon" />
|
||||
</div>
|
||||
<div
|
||||
v-if="isDatePanelShow"
|
||||
:class="[n(['dialog'])]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
n([
|
||||
'dialog-container',
|
||||
'dialog-container-' + rangeTableType,
|
||||
'dialog-container-' + popupPosition,
|
||||
]),
|
||||
]"
|
||||
>
|
||||
<DatePickerPanel
|
||||
:type="props.type"
|
||||
rangeType="left"
|
||||
:date="dateValue"
|
||||
:startDate="startValue"
|
||||
:endDate="endValue"
|
||||
:currentOtherPanelValue="rightCurrentPanelValue"
|
||||
@pick="handlePick"
|
||||
@change="handleLeftPanelChange"
|
||||
/>
|
||||
<DatePickerPanel
|
||||
v-if="props.type === 'range' && isPC && rangeTableType === 'two'"
|
||||
:type="props.type"
|
||||
rangeType="right"
|
||||
:date="dateValue"
|
||||
:startDate="startValue"
|
||||
:endDate="endValue"
|
||||
:currentOtherPanelValue="leftCurrentPanelValue"
|
||||
@pick="handlePick"
|
||||
@change="handleRightPanelChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from '../../../adapter-vue';
|
||||
import { TUITranslateService } from '@tencentcloud/chat-uikit-engine';
|
||||
// dayjs extension
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import localeData from 'dayjs/plugin/localeData.js';
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter.js';
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore.js';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import DatePickerPanel from './date-picker-panel.vue';
|
||||
import { DateCell } from './date-picker';
|
||||
import { isPC, isUniFrameWork } from '../../../utils/env';
|
||||
|
||||
dayjs.extend(localeData);
|
||||
dayjs.extend(isSameOrAfter);
|
||||
dayjs.extend(isSameOrBefore);
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const emit = defineEmits(['pick', 'change']);
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'range', // "single" / "range"
|
||||
},
|
||||
rangeTableType: {
|
||||
type: String,
|
||||
default: 'one', // "one"/ "two"
|
||||
},
|
||||
startPlaceholder: {
|
||||
type: String,
|
||||
default: () => TUITranslateService.t('开始时间'),
|
||||
},
|
||||
endPlaceholder: {
|
||||
type: String,
|
||||
default: () => TUITranslateService.t('开始时间'),
|
||||
},
|
||||
popupPosition: {
|
||||
type: String,
|
||||
default: 'bottom', // "top" / "bottom"
|
||||
},
|
||||
// Default single-select date
|
||||
defaultSingleDate: {
|
||||
type: Dayjs,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
const isDatePanelShow = ref<boolean>(false);
|
||||
|
||||
const dateValue = ref<typeof Dayjs>(props.type === 'single' ? props?.defaultSingleDate : null);
|
||||
const startValue = ref<typeof Dayjs>(props.type === 'single' ? props?.defaultSingleDate : null);
|
||||
const endValue = ref<typeof Dayjs>(props.type === 'single' ? props?.defaultSingleDate : null);
|
||||
const startFormatDate = computed(() => startValue?.value?.format('YYYY/MM/DD'));
|
||||
const endFormatDate = computed(() => endValue?.value?.format('YYYY/MM/DD'));
|
||||
const startPlaceholderVal = props.startPlaceholder;
|
||||
const endPlaceholderVal = props.endPlaceholder;
|
||||
const leftCurrentPanelValue = ref<typeof Dayjs>();
|
||||
const rightCurrentPanelValue = ref<typeof Dayjs>();
|
||||
|
||||
const setDatePanelDisplay = (show: boolean) => {
|
||||
isDatePanelShow.value = show;
|
||||
};
|
||||
|
||||
const n = (classNameList: string[]) => {
|
||||
const resultClassList: string[] = [];
|
||||
classNameList.forEach((className: string) => {
|
||||
if (className) {
|
||||
resultClassList.push('tui-date-picker-' + className);
|
||||
!isPC && resultClassList.push('tui-date-picker-h5-' + className);
|
||||
} else {
|
||||
resultClassList.push('tui-date-picker');
|
||||
!isPC && resultClassList.push('tui-date-picker-h5');
|
||||
}
|
||||
});
|
||||
return resultClassList;
|
||||
};
|
||||
|
||||
const handlePick = (cell: DateCell) => {
|
||||
switch (props.type) {
|
||||
case 'single':
|
||||
startValue.value = cell.date;
|
||||
endValue.value = cell.date;
|
||||
dateValue.value = cell.date;
|
||||
emit('change', cell);
|
||||
emit('pick', dateValue.value);
|
||||
setTimeout(() => {
|
||||
setDatePanelDisplay(false);
|
||||
}, 300);
|
||||
break;
|
||||
case 'range':
|
||||
if (!startValue?.value) {
|
||||
startValue.value = cell.date;
|
||||
} else if (!endValue?.value) {
|
||||
if (startValue?.value?.isSameOrBefore(cell.date, 'day')) {
|
||||
endValue.value = cell.date;
|
||||
} else {
|
||||
endValue.value = startValue.value;
|
||||
startValue.value = cell.date;
|
||||
}
|
||||
emit('pick', {
|
||||
startDate: startValue?.value?.startOf('date'),
|
||||
endDate: endValue?.value?.endOf('date'),
|
||||
});
|
||||
setTimeout(() => {
|
||||
setDatePanelDisplay(false);
|
||||
}, 200);
|
||||
} else {
|
||||
startValue.value = cell.date;
|
||||
endValue.value = null;
|
||||
}
|
||||
emit('change', {
|
||||
startDate: startValue.value,
|
||||
endDate: endValue.value,
|
||||
leftCurrentPanel: leftCurrentPanelValue.value,
|
||||
rightCurrentPanel: leftCurrentPanelValue.value,
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeftPanelChange = (value: typeof Dayjs) => {
|
||||
leftCurrentPanelValue.value = value;
|
||||
emit('change', {
|
||||
startDate: startValue.value,
|
||||
endDate: endValue.value,
|
||||
leftCurrentPanel: leftCurrentPanelValue.value,
|
||||
rightCurrentPanel: leftCurrentPanelValue.value,
|
||||
});
|
||||
};
|
||||
const handleRightPanelChange = (value: typeof Dayjs) => {
|
||||
rightCurrentPanelValue.value = value;
|
||||
emit('change', {
|
||||
startDate: startValue.value,
|
||||
endDate: endValue.value,
|
||||
leftCurrentPanel: leftCurrentPanelValue.value,
|
||||
rightCurrentPanel: leftCurrentPanelValue.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.tui-date-picker {
|
||||
&-input {
|
||||
min-width: 160px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
color: #666;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
|
||||
&-start,
|
||||
&-end {
|
||||
flex: 1;
|
||||
color: #666;
|
||||
height: 17px;
|
||||
border: none;
|
||||
width: 67px;
|
||||
background-color: transparent;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
|
||||
&:focus {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-dialog {
|
||||
position: relative;
|
||||
|
||||
&-container {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 10px;
|
||||
left: 5px;
|
||||
background-color: #fff;
|
||||
box-shadow: rgba(0, 0, 0, 0.16) 0 3px 6px, rgba(0, 0, 0, 0.23) 0 3px 6px;
|
||||
z-index: 1000;
|
||||
|
||||
&-bottom {
|
||||
left: 5px;
|
||||
}
|
||||
|
||||
&-top {
|
||||
bottom: 30px;
|
||||
}
|
||||
|
||||
&-one {
|
||||
left: -5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
TUIKit/components/common/Dialog/index.ts
Normal file
3
TUIKit/components/common/Dialog/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Dialog from './index.vue';
|
||||
|
||||
export default Dialog;
|
||||
119
TUIKit/components/common/Dialog/index.vue
Normal file
119
TUIKit/components/common/Dialog/index.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="showDialog"
|
||||
class="dialog"
|
||||
:class="[!isPC ? 'dialog-h5' : '', center ? 'center' : '']"
|
||||
@click.stop.prevent="toggleView(clickType.OUTSIDE)"
|
||||
>
|
||||
<main
|
||||
class="dialog-main"
|
||||
:class="[!backgroundDialog ? 'dialog-main-back' : '']"
|
||||
@click.stop.prevent="toggleView(clickType.INSIDE)"
|
||||
>
|
||||
<header
|
||||
v-if="isHeaderShowDialog"
|
||||
class="dialog-main-header"
|
||||
>
|
||||
<h1 class="dialog-main-title">
|
||||
{{ showTitle }}
|
||||
</h1>
|
||||
<i
|
||||
class="icon icon-close"
|
||||
@click="close"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="dialog-main-content"
|
||||
:class="[isUniFrameWork && isH5 ? 'dialog-main-content-uniapp' : '']"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<footer
|
||||
v-if="isFooterShowDialog"
|
||||
class="dialog-main-footer"
|
||||
>
|
||||
<button
|
||||
class="btn btn-cancel"
|
||||
@click="close"
|
||||
>
|
||||
{{ TUITranslateService.t('component.取消') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-default"
|
||||
@click="submit"
|
||||
>
|
||||
{{ TUITranslateService.t('component.确定') }}
|
||||
</button>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watchEffect } from '../../../adapter-vue';
|
||||
import { TUITranslateService } from '@tencentcloud/chat-uikit-engine';
|
||||
import { isPC, isH5, isUniFrameWork } from '../../../utils/env';
|
||||
const clickType = {
|
||||
OUTSIDE: 'outside',
|
||||
INSIDE: 'inside',
|
||||
};
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isHeaderShow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isFooterShow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
background: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
center: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const showDialog = ref(false);
|
||||
const isHeaderShowDialog = ref(true);
|
||||
const isFooterShowDialog = ref(true);
|
||||
const backgroundDialog = ref(true);
|
||||
const showTitle = ref('');
|
||||
|
||||
watchEffect(() => {
|
||||
showDialog.value = props.show;
|
||||
showTitle.value = props.title;
|
||||
isHeaderShowDialog.value = props.isHeaderShow;
|
||||
isFooterShowDialog.value = props.isFooterShow;
|
||||
backgroundDialog.value = props.background;
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:show', 'submit']);
|
||||
|
||||
const toggleView = (type: string) => {
|
||||
if (type === clickType.OUTSIDE) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
showDialog.value = !showDialog.value;
|
||||
emit('update:show', showDialog.value);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
emit('submit');
|
||||
close();
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped src="./style/dialog.scss"></style>
|
||||
43
TUIKit/components/common/Dialog/style/color.scss
Normal file
43
TUIKit/components/common/Dialog/style/color.scss
Normal file
@@ -0,0 +1,43 @@
|
||||
.dialog {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
|
||||
&-main {
|
||||
background: #FFF;
|
||||
|
||||
&-header {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-family: PingFangSC-Medium;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
&-back {
|
||||
background: none;
|
||||
}
|
||||
|
||||
&-content {
|
||||
font-weight: 400;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-weight: 400;
|
||||
color: #FFF;
|
||||
letter-spacing: 0;
|
||||
|
||||
&-cancel {
|
||||
border: 1px solid #ddd;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
&-default {
|
||||
background: #006EFF;
|
||||
border: 1px solid #006EFF;
|
||||
}
|
||||
}
|
||||
4
TUIKit/components/common/Dialog/style/dialog.scss
Normal file
4
TUIKit/components/common/Dialog/style/dialog.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
@import '../../../../assets/styles/common';
|
||||
@import "./color";
|
||||
@import "./web";
|
||||
@import "./h5";
|
||||
56
TUIKit/components/common/Dialog/style/h5.scss
Normal file
56
TUIKit/components/common/Dialog/style/h5.scss
Normal file
@@ -0,0 +1,56 @@
|
||||
.dialog-h5 {
|
||||
height: 100%;
|
||||
top: 0;
|
||||
align-items: inherit;
|
||||
|
||||
.dialog {
|
||||
&-main {
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
min-width: 120px;
|
||||
|
||||
&-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
|
||||
&-uniapp {
|
||||
padding: 40px 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-footer {
|
||||
border-top: 1px solid #DDD;
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
background: none;
|
||||
border-right: 1px solid #DDD;
|
||||
|
||||
&-default {
|
||||
color: #FF584C;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.center {
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
61
TUIKit/components/common/Dialog/style/web.scss
Normal file
61
TUIKit/components/common/Dialog/style/web.scss
Normal file
@@ -0,0 +1,61 @@
|
||||
.dialog {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&-main {
|
||||
min-width: 368px;
|
||||
border-radius: 10px;
|
||||
padding: 20px 30px;
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 16px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
&-content {
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
&-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 20px;
|
||||
margin: 0 6px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
line-height: 20px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
159
TUIKit/components/common/Drawer/index.vue
Normal file
159
TUIKit/components/common/Drawer/index.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<Overlay
|
||||
ref="overlayDomInstanceRef"
|
||||
:visible="props.visible"
|
||||
:useMask="props.useMask"
|
||||
:maskColor="props.overlayColor"
|
||||
:isFullScreen="props.isFullScreen"
|
||||
@onOverlayClick="onOverlayClick"
|
||||
>
|
||||
<div
|
||||
v-if="isDrawerShow"
|
||||
ref="drawerDomRef"
|
||||
:class="{
|
||||
'drawer': true,
|
||||
'origin-bottom': props.popDirection === 'bottom',
|
||||
'origin-right': props.popDirection === 'right',
|
||||
'slide-bottom': visible && props.popDirection === 'bottom',
|
||||
'slide-right': visible && props.popDirection === 'right',
|
||||
}"
|
||||
:style="{
|
||||
minHeight: styles.minHeight,
|
||||
maxHeight: styles.maxHeight,
|
||||
borderRadius: styles.borderRadius,
|
||||
boxShadow: styles.boxShadow,
|
||||
width: styles.width,
|
||||
}"
|
||||
>
|
||||
<div class="drawer-container">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</Overlay>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from '../../../adapter-vue';
|
||||
import Overlay from '../../common/Overlay/index.vue';
|
||||
|
||||
interface IProps {
|
||||
visible: boolean;
|
||||
popDirection: 'top' | 'right' | 'bottom' | 'left';
|
||||
useMask?: boolean;
|
||||
isFullScreen?: boolean | undefined;
|
||||
overlayColor?: string | undefined;
|
||||
drawerStyle?: {
|
||||
bottom?: Record<string, any> | undefined;
|
||||
right?: Record<string, any> | undefined;
|
||||
left?: Record<string, any> | undefined;
|
||||
top?: Record<string, any> | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(e: 'onOverlayClick', event: Event): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
visible: true,
|
||||
useMask: true,
|
||||
isFullScreen: true,
|
||||
popDirection: 'bottom',
|
||||
drawerStyle: () => ({}),
|
||||
});
|
||||
|
||||
const drawerDomRef = ref<HTMLElement>();
|
||||
const overlayDomInstanceRef = ref<InstanceType<typeof Overlay>>();
|
||||
const isDrawerShow = ref<boolean>(false);
|
||||
|
||||
const styles = ref(props.drawerStyle[props.popDirection] || {});
|
||||
|
||||
watch(() => props.visible, (visible: boolean) => {
|
||||
if (visible) {
|
||||
isDrawerShow.value = true;
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
isDrawerShow.value = false;
|
||||
}, 150);
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
function onOverlayClick(e: Event) {
|
||||
emits('onOverlayClick', e);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
drawerDomRef,
|
||||
overlayDomRef: overlayDomInstanceRef.value?.overlayDomRef,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
background-color: #fff;
|
||||
overflow: hidden;
|
||||
transition: transform 0.15s ease-out;
|
||||
|
||||
.drawer-container {
|
||||
background-color: #fff;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.origin-bottom {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transform: translateY(100%);
|
||||
animation: slide-from-bottom 0.15s ease-out;
|
||||
}
|
||||
|
||||
.origin-right {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
transform: translateX(100%);
|
||||
animation: slide-from-right 0.15s ease-out;
|
||||
}
|
||||
|
||||
.slide-bottom {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.slide-right {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
@keyframes slide-from-bottom {
|
||||
0% {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-from-right {
|
||||
0% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
96
TUIKit/components/common/FetchMore/index.vue
Normal file
96
TUIKit/components/common/FetchMore/index.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div
|
||||
ref="selfDomRef"
|
||||
class="fetch-more-block"
|
||||
>
|
||||
<template v-if="props.isFetching">
|
||||
<slot name="fetching">
|
||||
<div>{{ TUITranslateService.t("TUIChat.正在加载") }}</div>
|
||||
</slot>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot name="fetchEnd">
|
||||
<div>{{ TUITranslateService.t("TUIChat.加载结束") }}</div>
|
||||
</slot>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch, getCurrentInstance, withDefaults } from '../../../adapter-vue';
|
||||
import { TUITranslateService } from '@tencentcloud/chat-uikit-engine';
|
||||
import { isUniFrameWork } from '../../../utils/env';
|
||||
|
||||
interface IProps {
|
||||
isFetching: boolean;
|
||||
isTerminateObserve?: boolean;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(e: 'onExposed'): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
isFetching: false,
|
||||
isTerminateObserve: false,
|
||||
});
|
||||
|
||||
let observer: unknown = null;
|
||||
const selfDomRef = ref();
|
||||
const thisInstance = getCurrentInstance()?.proxy || getCurrentInstance();
|
||||
|
||||
onMounted(() => {
|
||||
if (props.isTerminateObserve) {
|
||||
return;
|
||||
}
|
||||
if (!isUniFrameWork) {
|
||||
observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
emits('onExposed');
|
||||
}
|
||||
}, {
|
||||
threshold: 1.0,
|
||||
});
|
||||
if (selfDomRef.value) {
|
||||
(observer as IntersectionObserver).observe(selfDomRef.value);
|
||||
}
|
||||
} else {
|
||||
observer = uni.createIntersectionObserver(thisInstance).relativeToViewport();
|
||||
(observer as any).observe('.fetch-more-block', () => {
|
||||
emits('onExposed');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (observer) {
|
||||
(observer as IntersectionObserver).disconnect();
|
||||
observer = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.isTerminateObserve, (isTerminateObserve: boolean) => {
|
||||
if (!observer) {
|
||||
return;
|
||||
}
|
||||
if (isTerminateObserve) {
|
||||
(observer as IntersectionObserver).disconnect();
|
||||
} else if (selfDomRef.value) {
|
||||
(observer as IntersectionObserver).disconnect();
|
||||
if (!isUniFrameWork) {
|
||||
(observer as IntersectionObserver).observe(selfDomRef.value);
|
||||
} else {
|
||||
(observer as any).observe('.fetch-more-block', () => {
|
||||
emits('onExposed');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fetch-more-block {
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
82
TUIKit/components/common/Icon.vue
Normal file
82
TUIKit/components/common/Icon.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<!-- eslint-disable vue/multi-word-component-names -->
|
||||
<template>
|
||||
<div
|
||||
:class="['common-icon-container', !isPC && 'common-icon-container-mobile']"
|
||||
:style="{
|
||||
padding: iconHotAreaSize,
|
||||
}"
|
||||
@click="handleImgClick"
|
||||
>
|
||||
<image
|
||||
v-if="isApp"
|
||||
class="common-icon"
|
||||
:src="props.file"
|
||||
:style="{ width: iconWidth, height: iconHeight }"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
class="common-icon"
|
||||
:src="props.file"
|
||||
:style="{ width: iconWidth, height: iconHeight }"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { withDefaults, computed } from '../../adapter-vue';
|
||||
import { isApp, isPC } from '../../utils/env';
|
||||
|
||||
interface IProps {
|
||||
file: string;
|
||||
size?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
hotAreaSize?: number | string;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(key: 'onClick', e: Event): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
file: '',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
});
|
||||
|
||||
const iconHotAreaSize = computed<undefined | string>(() => {
|
||||
if (!props.hotAreaSize) {
|
||||
return undefined;
|
||||
}
|
||||
if (isNaN(Number(props.hotAreaSize))) {
|
||||
return String(props.hotAreaSize);
|
||||
}
|
||||
return `${props.hotAreaSize}px`;
|
||||
});
|
||||
|
||||
const iconWidth = computed(() => {
|
||||
return props.size ? props.size : props.width;
|
||||
});
|
||||
|
||||
const iconHeight = computed(() => {
|
||||
return props.size ? props.size : props.height;
|
||||
});
|
||||
|
||||
const handleImgClick = (e: Event) => {
|
||||
emits('onClick', e);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.common-icon-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.common-icon-container-mobile{
|
||||
cursor: none;
|
||||
}
|
||||
</style>
|
||||
79
TUIKit/components/common/ImagePreviewer/image-item.vue
Normal file
79
TUIKit/components/common/ImagePreviewer/image-item.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<movable-area
|
||||
class="image-item"
|
||||
scale-area
|
||||
>
|
||||
<movable-view
|
||||
class="image-item"
|
||||
direction="all"
|
||||
:out-of-bounds="false"
|
||||
:inertia="true"
|
||||
damping="90"
|
||||
friction="2"
|
||||
scale="true"
|
||||
scale-min="1"
|
||||
scale-max="4"
|
||||
scale-value="1"
|
||||
>
|
||||
<img
|
||||
class="image-preview"
|
||||
:class="[isWidth ? 'is-width' : 'isHeight']"
|
||||
mode="widthFix"
|
||||
:style="{
|
||||
transform: `scale(${props.zoom}) rotate(${props.rotate}deg)`,
|
||||
}"
|
||||
:src="props.src"
|
||||
:date-src="props.src"
|
||||
@click.stop
|
||||
>
|
||||
</movable-view>
|
||||
</movable-area>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from '../../../adapter-vue';
|
||||
import { IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
const props = defineProps({
|
||||
zoom: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
rotate: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
src: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
messageItem: {
|
||||
type: Object,
|
||||
default: () => ({} as IMessageModel),
|
||||
},
|
||||
});
|
||||
const isWidth = computed(() => {
|
||||
const { width = 0, height = 0 }
|
||||
= props.messageItem?.payload?.imageInfoArray?.[0] || {};
|
||||
return width >= height;
|
||||
});
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.image-item {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
transition: transform 0.1s ease 0s;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.is-width {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
3
TUIKit/components/common/ImagePreviewer/index.ts
Normal file
3
TUIKit/components/common/ImagePreviewer/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import ImagePreviewer from './index.vue';
|
||||
|
||||
export default ImagePreviewer;
|
||||
678
TUIKit/components/common/ImagePreviewer/index.vue
Normal file
678
TUIKit/components/common/ImagePreviewer/index.vue
Normal file
@@ -0,0 +1,678 @@
|
||||
<template>
|
||||
<div
|
||||
class="image-previewer"
|
||||
:class="[isMobile && 'image-previewer-h5']"
|
||||
>
|
||||
<div
|
||||
ref="image"
|
||||
class="image-wrapper"
|
||||
@touchstart.stop="handleTouchStart"
|
||||
@touchmove.stop="handleTouchMove"
|
||||
@touchend.stop="handleTouchEnd"
|
||||
@touchcancel.stop="handleTouchCancel"
|
||||
@wheel.stop="handleWheel"
|
||||
>
|
||||
<ul
|
||||
ref="ulRef"
|
||||
class="image-list"
|
||||
:style="{
|
||||
width: `${imageList.length * 100}%`,
|
||||
transform: `translateX(-${
|
||||
(currentImageIndex * 100) / imageList.length
|
||||
}%)`,
|
||||
transition: '0.5s',
|
||||
}"
|
||||
>
|
||||
<li
|
||||
v-for="(item, index) in imageList"
|
||||
:key="index"
|
||||
class="image-item"
|
||||
>
|
||||
<ImageItem
|
||||
:zoom="zoom"
|
||||
:rotate="rotate"
|
||||
:src="getImageUrl(item)"
|
||||
:messageItem="item"
|
||||
:class="[isUniFrameWork ? 'image-item' : '']"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-show="isPC"
|
||||
class="icon icon-close"
|
||||
@click="close"
|
||||
>
|
||||
<Icon
|
||||
:file="iconClose"
|
||||
width="16px"
|
||||
height="16px"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isPC && currentImageIndex > 0"
|
||||
class="image-button image-button-left"
|
||||
@click="goPrev"
|
||||
>
|
||||
<Icon :file="iconArrowLeft" />
|
||||
</div>
|
||||
<div
|
||||
v-if="isPC && currentImageIndex < imageList.length - 1"
|
||||
class="image-button image-button-right"
|
||||
@click="goNext"
|
||||
>
|
||||
<Icon :file="iconArrowLeft" />
|
||||
</div>
|
||||
<div :class="['actions-bar', isMobile && 'actions-bar-h5']">
|
||||
<div
|
||||
v-if="isPC"
|
||||
class="icon-zoom-in"
|
||||
@click="zoomIn"
|
||||
>
|
||||
<Icon
|
||||
:file="iconZoomIn"
|
||||
width="27px"
|
||||
height="27px"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isPC"
|
||||
class="icon-zoom-out"
|
||||
@click="zoomOut"
|
||||
>
|
||||
<Icon
|
||||
:file="iconZoomOut"
|
||||
width="27px"
|
||||
height="27px"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isPC"
|
||||
class="icon-refresh-left"
|
||||
@click="rotateLeft"
|
||||
>
|
||||
<Icon
|
||||
:file="iconRotateLeft"
|
||||
width="27px"
|
||||
height="27px"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isPC"
|
||||
class="icon-refresh-right"
|
||||
@click="rotateRight"
|
||||
>
|
||||
<Icon
|
||||
:file="iconRotateRight"
|
||||
width="27px"
|
||||
height="27px"
|
||||
/>
|
||||
</div>
|
||||
<span class="image-counter">
|
||||
{{ currentImageIndex + 1 }} / {{ imageList.length }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="save"
|
||||
@click.stop.prevent="save"
|
||||
>
|
||||
<Icon
|
||||
:file="iconDownload"
|
||||
width="20px"
|
||||
height="20px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watchEffect, onMounted, onUnmounted, withDefaults } from '../../../adapter-vue';
|
||||
import { IMessageModel, TUITranslateService } from '@tencentcloud/chat-uikit-engine';
|
||||
import { TUIGlobal, getPlatform } from '@tencentcloud/universal-api';
|
||||
import Icon from '../../common/Icon.vue';
|
||||
import iconClose from '../../../assets/icon/icon-close.svg';
|
||||
import iconArrowLeft from '../../../assets/icon/icon-arrow-left.svg';
|
||||
import iconZoomIn from '../../../assets/icon/zoom-in.svg';
|
||||
import iconZoomOut from '../../../assets/icon/zoom-out.svg';
|
||||
import iconRotateLeft from '../../../assets/icon/rotate-left.svg';
|
||||
import iconRotateRight from '../../../assets/icon/rotate-right.svg';
|
||||
import iconDownload from '../../../assets/icon/download.svg';
|
||||
import ImageItem from './image-item.vue';
|
||||
import { Toast, TOAST_TYPE } from '../../common/Toast/index';
|
||||
import { isPC, isMobile, isUniFrameWork } from '../../../utils/env';
|
||||
|
||||
interface touchesPosition {
|
||||
pageX1?: number;
|
||||
pageY1?: number;
|
||||
pageX2?: number;
|
||||
pageY2?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
imageList: IMessageModel[];
|
||||
currentImage: IMessageModel;
|
||||
}>(),
|
||||
{
|
||||
imageList: () => ([] as IMessageModel[]),
|
||||
messageItem: () => ({} as IMessageModel),
|
||||
},
|
||||
);
|
||||
|
||||
const imageFormatMap = new Map([
|
||||
[1, 'jpg'],
|
||||
[2, 'gif'],
|
||||
[3, 'png'],
|
||||
[4, 'bmp'],
|
||||
]);
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const zoom = ref(1);
|
||||
const rotate = ref(0);
|
||||
const minZoom = ref(0.1);
|
||||
const currentImageIndex = ref(0);
|
||||
const image = ref();
|
||||
const ulRef = ref();
|
||||
// touch
|
||||
let startX = 0;
|
||||
const touchStore = {} as touchesPosition;
|
||||
let moveFlag = false;
|
||||
let twoTouchesFlag = false;
|
||||
let timer: number | null = null;
|
||||
|
||||
watchEffect(() => {
|
||||
currentImageIndex.value = props.imageList.findIndex((message: any) => {
|
||||
return message.ID === props?.currentImage?.ID;
|
||||
});
|
||||
});
|
||||
|
||||
const isNumber = (value: any) => {
|
||||
return typeof value === 'number' && isFinite(value);
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: any) => {
|
||||
e.preventDefault();
|
||||
moveInit(e);
|
||||
twoTouchesInit(e);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: any) => {
|
||||
e.preventDefault();
|
||||
moveFlag = true;
|
||||
if (e.touches && e.touches.length === 2) {
|
||||
twoTouchesFlag = true;
|
||||
handleTwoTouches(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
let moveEndX = 0;
|
||||
let X = 0;
|
||||
if (twoTouchesFlag) {
|
||||
if (!timer) {
|
||||
twoTouchesFlag = false;
|
||||
delete touchStore.pageX2;
|
||||
delete touchStore.pageY2;
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
}, 200);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// H5 touch move to left to go to prev image
|
||||
// H5 touch move to right to go to next image
|
||||
if (timer === null) {
|
||||
switch (moveFlag) {
|
||||
// touch event
|
||||
case true:
|
||||
moveEndX = e?.changedTouches[0]?.pageX;
|
||||
X = moveEndX - startX;
|
||||
if (X > 100) {
|
||||
goPrev();
|
||||
} else if (X < -100) {
|
||||
goNext();
|
||||
}
|
||||
break;
|
||||
// click event
|
||||
case false:
|
||||
close();
|
||||
break;
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
}, 200);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchCancel = () => {
|
||||
twoTouchesFlag = false;
|
||||
delete touchStore.pageX1;
|
||||
delete touchStore.pageY1;
|
||||
};
|
||||
|
||||
const handleWheel = (e: any) => {
|
||||
e.preventDefault();
|
||||
if (Math.abs(e.deltaX) !== 0 && Math.abs(e.deltaY) !== 0) return;
|
||||
let scale = zoom.value;
|
||||
scale += e.deltaY * (e.ctrlKey ? -0.01 : 0.002);
|
||||
scale = Math.min(Math.max(0.125, scale), 4);
|
||||
zoom.value = scale;
|
||||
};
|
||||
|
||||
const moveInit = (e: any) => {
|
||||
startX = e?.changedTouches[0]?.pageX;
|
||||
moveFlag = false;
|
||||
};
|
||||
|
||||
const twoTouchesInit = (e: any) => {
|
||||
const touch1 = e?.touches[0];
|
||||
const touch2 = e?.touches[1];
|
||||
touchStore.pageX1 = touch1?.pageX;
|
||||
touchStore.pageY1 = touch1?.pageY;
|
||||
if (touch2) {
|
||||
touchStore.pageX2 = touch2?.pageX;
|
||||
touchStore.pageY2 = touch2?.pageY;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTwoTouches = (e: any) => {
|
||||
const touch1 = e?.touches[0];
|
||||
const touch2 = e?.touches[1];
|
||||
if (touch2) {
|
||||
if (!isNumber(touchStore.pageX2)) {
|
||||
touchStore.pageX2 = touch2.pageX;
|
||||
}
|
||||
if (!isNumber(touchStore.pageY2)) {
|
||||
touchStore.pageY2 = touch2.pageY;
|
||||
}
|
||||
}
|
||||
const getDistance = (
|
||||
startX: number,
|
||||
startY: number,
|
||||
stopX: number,
|
||||
stopY: number,
|
||||
) => {
|
||||
return Math.hypot(stopX - startX, stopY - startY);
|
||||
};
|
||||
if (
|
||||
!isNumber(touchStore.pageX1)
|
||||
|| !isNumber(touchStore.pageY1)
|
||||
|| !isNumber(touchStore.pageX2)
|
||||
|| !isNumber(touchStore.pageY2)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const touchZoom
|
||||
= getDistance(touch1.pageX, touch1.pageY, touch2.pageX, touch2.pageY)
|
||||
/ getDistance(
|
||||
touchStore.pageX1 as number,
|
||||
touchStore.pageY1 as number,
|
||||
touchStore.pageX2 as number,
|
||||
touchStore.pageY2 as number,
|
||||
);
|
||||
zoom.value = Math.min(Math.max(0.5, zoom.value * touchZoom), 4);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// web: close on esc keydown
|
||||
document?.addEventListener
|
||||
&& document?.addEventListener('keydown', handleEsc);
|
||||
});
|
||||
|
||||
const handleEsc = (e: any) => {
|
||||
e.preventDefault();
|
||||
if (e?.keyCode === 27) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
const zoomIn = () => {
|
||||
zoom.value += 0.1;
|
||||
};
|
||||
const zoomOut = () => {
|
||||
zoom.value
|
||||
= zoom.value - 0.1 > minZoom.value ? zoom.value - 0.1 : minZoom.value;
|
||||
};
|
||||
const close = () => {
|
||||
emit('close');
|
||||
};
|
||||
const rotateLeft = () => {
|
||||
rotate.value -= 90;
|
||||
};
|
||||
const rotateRight = () => {
|
||||
rotate.value += 90;
|
||||
};
|
||||
const goNext = () => {
|
||||
currentImageIndex.value < props.imageList.length - 1
|
||||
&& currentImageIndex.value++;
|
||||
initStyle();
|
||||
};
|
||||
const goPrev = () => {
|
||||
currentImageIndex.value > 0 && currentImageIndex.value--;
|
||||
initStyle();
|
||||
};
|
||||
const initStyle = () => {
|
||||
zoom.value = 1;
|
||||
rotate.value = 0;
|
||||
};
|
||||
|
||||
const getImageUrl = (message: IMessageModel) => {
|
||||
if (isPC) {
|
||||
return message?.payload?.imageInfoArray[0]?.url;
|
||||
} else {
|
||||
return message?.payload?.imageInfoArray[2]?.url;
|
||||
}
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const imageMessage = props.imageList[
|
||||
currentImageIndex.value
|
||||
] as IMessageModel;
|
||||
const imageSrc = imageMessage?.payload?.imageInfoArray[0]?.url;
|
||||
if (!imageSrc) {
|
||||
Toast({
|
||||
message: TUITranslateService.t('component.图片 url 不存在'),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
return;
|
||||
}
|
||||
switch (getPlatform()) {
|
||||
case 'wechat':
|
||||
// Get the user's current settings and get the album permissions
|
||||
TUIGlobal.getSetting({
|
||||
success: (res: any) => {
|
||||
if (!res?.authSetting['scope.writePhotosAlbum']) {
|
||||
TUIGlobal.authorize({
|
||||
scope: 'scope.writePhotosAlbum',
|
||||
success() {
|
||||
downloadImgInUni(imageSrc);
|
||||
},
|
||||
fail() {
|
||||
TUIGlobal.showModal({
|
||||
title: '您已拒绝获取相册权限',
|
||||
content: '是否进入权限管理,调整授权?',
|
||||
success: (res: any) => {
|
||||
if (res.confirm) {
|
||||
// Call up the client applet settings interface and return the operation results set by the user.
|
||||
// Ask the user to authorize again.
|
||||
TUIGlobal.openSetting({
|
||||
success: (res: any) => {
|
||||
console.log(res.authSetting);
|
||||
},
|
||||
});
|
||||
} else if (res.cancel) {
|
||||
return Toast({
|
||||
message: TUITranslateService.t('component.已取消'),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// If you already have album permission, save directly to the album
|
||||
downloadImgInUni(imageSrc);
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
Toast({
|
||||
message: TUITranslateService.t('component.获取权限失败'),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'app':
|
||||
downloadImgInUni(imageSrc);
|
||||
break;
|
||||
default:
|
||||
downloadImgInWeb(imageSrc);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadImgInUni = (src: string) => {
|
||||
TUIGlobal.showLoading({
|
||||
title: '大图提取中',
|
||||
});
|
||||
TUIGlobal.downloadFile({
|
||||
url: src,
|
||||
success: function (res: any) {
|
||||
TUIGlobal.hideLoading();
|
||||
TUIGlobal.saveImageToPhotosAlbum({
|
||||
filePath: res.tempFilePath,
|
||||
success: () => {
|
||||
Toast({
|
||||
message: TUITranslateService.t('component.已保存至相册'),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
fail: function () {
|
||||
TUIGlobal.hideLoading();
|
||||
Toast({
|
||||
message: TUITranslateService.t('component.图片下载失败'),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const downloadImgInWeb = (src: string) => {
|
||||
const option: any = {
|
||||
mode: 'cors',
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}),
|
||||
};
|
||||
const imageMessage = props.imageList[
|
||||
currentImageIndex.value
|
||||
] as IMessageModel;
|
||||
const imageFormat: number = imageMessage?.payload?.imageFormat;
|
||||
if (!imageFormatMap.has(imageFormat)) {
|
||||
Toast({
|
||||
message: TUITranslateService.t('component.暂不支持下载此类型图片'),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If the browser supports fetch, use blob to download, so as to avoid the browser clicking the a tag and jumping to the preview of the new page
|
||||
if ((window as any).fetch) {
|
||||
fetch(src, option)
|
||||
.then(res => res.blob())
|
||||
.then((blob) => {
|
||||
const a = document.createElement('a');
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
a.href = url;
|
||||
a.download = url + '.' + imageFormatMap.get(imageFormat);
|
||||
a.click();
|
||||
});
|
||||
} else {
|
||||
const a = document.createElement('a');
|
||||
a.href = src;
|
||||
a.target = '_blank';
|
||||
a.download = src + '.' + imageFormatMap.get(imageFormat);
|
||||
a.click();
|
||||
}
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
document?.removeEventListener
|
||||
&& document?.removeEventListener('keydown', handleEsc);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../assets/styles/common";
|
||||
|
||||
.actions-bar {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
bottom: 5%;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
|
||||
&-h5 {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: static;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.icon-zoom-in,
|
||||
.icon-zoom-out,
|
||||
.icon-refresh-left,
|
||||
.icon-refresh-right {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.image-previewer {
|
||||
position: fixed;
|
||||
z-index: 101;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(#000, 0.3);
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
|
||||
.image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-list {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
place-content: center center;
|
||||
|
||||
.image-item {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
transition: transform 0.1s ease 0s;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.image-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: auto;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
top: calc(50% - 20px);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
|
||||
&-left {
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
&-right {
|
||||
right: 10px;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
line-height: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: auto;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-close {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
top: 3%;
|
||||
right: 3%;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
background-color: #444;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-previewer-h5 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.save {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
bottom: 5%;
|
||||
right: 5%;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.image-counter {
|
||||
background: rgba(20, 18, 20, 0.53);
|
||||
padding: 3px 5px;
|
||||
margin: 5px;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
2
TUIKit/components/common/Loading/index.ts
Normal file
2
TUIKit/components/common/Loading/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import Loading from './index.vue';
|
||||
export default Loading;
|
||||
47
TUIKit/components/common/Loading/index.vue
Normal file
47
TUIKit/components/common/Loading/index.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div
|
||||
class="tui-loading"
|
||||
:style="{
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
border: `2px solid ${props.color}`,
|
||||
borderTopColor: 'transparent',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
width: {
|
||||
type: String,
|
||||
default: '30px',
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '30px',
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '#D9D9D9',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.tui-loading {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 2px solid #d9d9d9;
|
||||
border-top-color: transparent;
|
||||
border-radius: 100%;
|
||||
animation: circle infinite 0.75s linear;
|
||||
}
|
||||
|
||||
@keyframes circle {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
56
TUIKit/components/common/MaskLayer/index.vue
Normal file
56
TUIKit/components/common/MaskLayer/index.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="showMask"
|
||||
class="mask"
|
||||
@click.self="!isWeChat && toggleView"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watchEffect } from '../../../adapter-vue';
|
||||
import { isWeChat } from '../../../utils/env';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
const showMask = ref(false);
|
||||
|
||||
watchEffect(() => {
|
||||
showMask.value = props.show;
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:show']);
|
||||
|
||||
const toggleView = () => {
|
||||
showMask.value = !showMask.value;
|
||||
emit('update:show', showMask.value);
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../../../assets/styles/common';
|
||||
|
||||
.mask {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 99;
|
||||
background: rgba(#000, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
main {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
129
TUIKit/components/common/Overlay/index.vue
Normal file
129
TUIKit/components/common/Overlay/index.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isOverlayShow"
|
||||
ref="overlayDomRef"
|
||||
class="overlay-container"
|
||||
:style="{
|
||||
position: props.isFullScreen ? 'fixed' : 'absolute',
|
||||
zIndex: props.zIndex,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="props.useMask"
|
||||
:class="{
|
||||
'overlay-mask': true,
|
||||
'fade-in': props.visible,
|
||||
}"
|
||||
:style="{
|
||||
backgroundColor: props.maskColor,
|
||||
}"
|
||||
@click="onOverlayClick"
|
||||
@touchstart.prevent.stop="onOverlayClick"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
'overlay-content': true,
|
||||
'full-screen': props.isFullScreen,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, withDefaults } from '../../../adapter-vue';
|
||||
export interface IOverlayProps {
|
||||
visible?: boolean;
|
||||
zIndex?: number | undefined;
|
||||
useMask?: boolean | undefined;
|
||||
maskColor?: string | undefined;
|
||||
isFullScreen?: boolean | undefined;
|
||||
width?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const emits = defineEmits(['onOverlayClick']);
|
||||
|
||||
const props = withDefaults(defineProps<IOverlayProps>(), {
|
||||
visible: true,
|
||||
zIndex: 9999,
|
||||
useMask: true,
|
||||
isFullScreen: true,
|
||||
maskColor: 'rgba(0, 0, 0, 0.6)',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
});
|
||||
|
||||
const overlayDomRef = ref<HTMLElement>();
|
||||
const isOverlayShow = ref<boolean>(props.visible);
|
||||
|
||||
watch(() => props.visible, (visible: boolean) => {
|
||||
if (visible) {
|
||||
isOverlayShow.value = true;
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
isOverlayShow.value = false;
|
||||
}, 150);
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
function onOverlayClick() {
|
||||
emits('onOverlayClick');
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
overlayDomRef,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.overlay-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.overlay-mask {
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s linear;
|
||||
animation: fade-in 0.15s linear;
|
||||
}
|
||||
|
||||
.full-screen{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-mask.fade-in {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
188
TUIKit/components/common/Popconfirm/index.vue
Normal file
188
TUIKit/components/common/Popconfirm/index.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="_isShow"
|
||||
class="pop"
|
||||
@click.stop.prevent="toggleView(clickType.OUTSIDE)"
|
||||
>
|
||||
<main
|
||||
class="pop-main"
|
||||
:class="[!isPC ? 'pop-main-h5' : '']"
|
||||
@click.stop.prevent="toggleView(clickType.INSIDE)"
|
||||
>
|
||||
<header
|
||||
v-if="isHeaderShow"
|
||||
class="pop-main-header"
|
||||
>
|
||||
<h1 class="pop-main-title">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</header>
|
||||
<div
|
||||
class="pop-main-content"
|
||||
:class="[isUniFrameWork && isH5 ? 'pop-main-content-uniapp' : '']"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<footer class="pop-main-footer">
|
||||
<button
|
||||
v-if="isConfirmButtonShow"
|
||||
class="btn btn-confirm"
|
||||
@click="popConfirm()"
|
||||
>
|
||||
{{ TUITranslateService.t(confirmButtonText) }}
|
||||
</button>
|
||||
<button
|
||||
v-if="isCancelButtonShow"
|
||||
class="btn btn-cancel"
|
||||
@click="popCancel()"
|
||||
>
|
||||
{{ TUITranslateService.t(cancelButtonText) }}
|
||||
</button>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, toRefs, watchEffect } from '../../../adapter-vue';
|
||||
import {
|
||||
TUIGlobal,
|
||||
TUITranslateService,
|
||||
} from '@tencentcloud/chat-uikit-engine';
|
||||
import { isUniFrameWork } from '../../../utils/env';
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
isShow: boolean;
|
||||
title?: string;
|
||||
isHeaderShow?: boolean;
|
||||
isConfirmButtonShow?: boolean;
|
||||
isCancelButtonShow?: boolean;
|
||||
confirmButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
}>(),
|
||||
{
|
||||
isShow: false,
|
||||
isHeaderShow: false,
|
||||
isConfirmButtonShow: true,
|
||||
isCancelButtonShow: true,
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
},
|
||||
);
|
||||
|
||||
const { isShow } = toRefs(props);
|
||||
const clickType = {
|
||||
OUTSIDE: 'outside',
|
||||
INSIDE: 'inside',
|
||||
};
|
||||
const isPC = ref(TUIGlobal.getPlatform() === 'pc');
|
||||
const isH5 = ref(TUIGlobal.getPlatform() === 'h5');
|
||||
const _isShow = ref<boolean>(false);
|
||||
const emit = defineEmits(['update:show', 'popConfirm']);
|
||||
|
||||
const toggleView = (type: string) => {
|
||||
if (type === clickType.OUTSIDE) {
|
||||
popCancel();
|
||||
}
|
||||
};
|
||||
watchEffect(() => {
|
||||
_isShow.value = isShow.value;
|
||||
});
|
||||
function popCancel() {
|
||||
_isShow.value = !_isShow.value;
|
||||
emit('update:show', _isShow.value);
|
||||
}
|
||||
|
||||
function popConfirm() {
|
||||
emit('popConfirm');
|
||||
popCancel();
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
$pop-bg-color: #fff;
|
||||
$pop-header: #333;
|
||||
$confirm-bg-color: #006EFF;
|
||||
$confirm-text-color: #fff;
|
||||
$concel-bg-color: #666;
|
||||
$content-color: #333;
|
||||
|
||||
.pop {
|
||||
background: rgba(0, 0, 0, .3);
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&-main {
|
||||
min-width: 368px;
|
||||
border-radius: 10px;
|
||||
padding: 20px 30px;
|
||||
max-width: 380px;
|
||||
background: $pop-bg-color;
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
line-height: 30px;
|
||||
font-weight: 500;
|
||||
color: $pop-header;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 16px;
|
||||
line-height: 30px;
|
||||
font-family: PingFangSC-Medium;
|
||||
font-weight: 500;
|
||||
color: $content-color;
|
||||
}
|
||||
|
||||
&-content {
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 400;
|
||||
color: $content-color;
|
||||
}
|
||||
|
||||
&-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pop-main-h5 {
|
||||
min-width: 220px;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 20px;
|
||||
margin: 0 6px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
line-height: 20px;
|
||||
|
||||
&-cancel {
|
||||
// border: 1px solid #dddddd;
|
||||
color: $concel-bg-color;
|
||||
}
|
||||
|
||||
&-confirm {
|
||||
background: $confirm-bg-color;
|
||||
border: 1px solid $confirm-bg-color;
|
||||
color: $confirm-text-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
92
TUIKit/components/common/ProgressMessage/index.vue
Normal file
92
TUIKit/components/common/ProgressMessage/index.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="progress-message">
|
||||
<slot />
|
||||
<div
|
||||
v-if="props.messageItem.status === 'unSend' && props.messageItem.progress < 1"
|
||||
class="progress-container"
|
||||
>
|
||||
<progress
|
||||
v-if="!isUniFrameWork"
|
||||
class="progress"
|
||||
:value="props.messageItem.progress"
|
||||
max="1"
|
||||
/>
|
||||
<progress
|
||||
v-else
|
||||
activeColor="#006EFF"
|
||||
class="progress-common"
|
||||
:percent="Math.round(props.messageItem.progress*100)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { IMessageModel } from '@tencentcloud/chat-uikit-engine';
|
||||
import { withDefaults } from '../../../adapter-vue';
|
||||
import { isUniFrameWork } from '../../../utils/env';
|
||||
import type { IImageMessageContent } from '../../../interface';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: IImageMessageContent;
|
||||
messageItem: IMessageModel;
|
||||
}>(),
|
||||
{
|
||||
content: () => ({}),
|
||||
messageItem: () => ({} as IMessageModel),
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$primary-progress-color: #006eff;
|
||||
$primary-progress-bg-color: #fff;
|
||||
|
||||
.progress-message {
|
||||
overflow: hidden;
|
||||
|
||||
.progress-container {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 15%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
background: rgba(#000, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.progress-common {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
.progress {
|
||||
@extend .progress-common;
|
||||
|
||||
color: $primary-progress-color;
|
||||
border-radius: 0.25rem;
|
||||
background: $primary-progress-bg-color;
|
||||
|
||||
&::-webkit-progress-value {
|
||||
background-color: $primary-progress-color;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
&::-webkit-progress-bar {
|
||||
border-radius: 0.25rem;
|
||||
background: $primary-progress-bg-color;
|
||||
}
|
||||
|
||||
&::-moz-progress-bar {
|
||||
color: $primary-progress-color;
|
||||
background: $primary-progress-color;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
61
TUIKit/components/common/RadioSelect/index.vue
Normal file
61
TUIKit/components/common/RadioSelect/index.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div
|
||||
class="radio-select"
|
||||
@click="toggleSelect"
|
||||
>
|
||||
<div
|
||||
v-if="!props.isSelected"
|
||||
class="radio-no-select"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
:file="radioIcon"
|
||||
:size="'20px'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Icon from '../Icon.vue';
|
||||
import radioIcon from '../../../assets/icon/radio.svg';
|
||||
|
||||
interface IProps {
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
interface IEmits {
|
||||
(e: 'onChange', value: boolean): void;
|
||||
}
|
||||
|
||||
const emits = defineEmits<IEmits>();
|
||||
const props = withDefaults(defineProps<IProps>(),
|
||||
{},
|
||||
);
|
||||
|
||||
function toggleSelect() {
|
||||
emits('onChange', !props.isSelected);
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
:not(not) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
box-sizing: border-box
|
||||
}
|
||||
|
||||
.radio-select {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
justify-content: center;
|
||||
|
||||
.radio-no-select {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ddd;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
TUIKit/components/common/SelectUser/index.ts
Normal file
3
TUIKit/components/common/SelectUser/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import SelectUser from './index.vue';
|
||||
|
||||
export default SelectUser;
|
||||
69
TUIKit/components/common/SelectUser/index.vue
Normal file
69
TUIKit/components/common/SelectUser/index.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:show="true"
|
||||
:isH5="!isPC"
|
||||
:isHeaderShow="false"
|
||||
:isFooterShow="false"
|
||||
:background="false"
|
||||
@update:show="reset"
|
||||
>
|
||||
<Transfer
|
||||
:isSearch="props.isNeedSearch"
|
||||
:title="props.title"
|
||||
:list="props.userList"
|
||||
:isH5="!isPC"
|
||||
:isRadio="props.isRadio"
|
||||
:total="props.total"
|
||||
@getMore="handleGetMore"
|
||||
@search="handleSearchUser"
|
||||
@submit="submit"
|
||||
@cancel="reset"
|
||||
/>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { isPC } from '../../../utils/env';
|
||||
import Dialog from '../Dialog/index.vue';
|
||||
import Transfer from '../Transfer/index.vue';
|
||||
|
||||
const emits = defineEmits(['complete', 'search', 'getMore']);
|
||||
|
||||
const props = defineProps({
|
||||
isRadio: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isNeedSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
userList: {
|
||||
type: Array,
|
||||
default: () => ([]),
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
emits('complete', []);
|
||||
};
|
||||
|
||||
const submit = (dataList: any) => {
|
||||
emits('complete', dataList);
|
||||
};
|
||||
|
||||
const handleSearchUser = (userID: string) => {
|
||||
emits('search', userID);
|
||||
};
|
||||
|
||||
const handleGetMore = () => {
|
||||
emits('getMore');
|
||||
};
|
||||
</script>
|
||||
64
TUIKit/components/common/Slider/index.vue
Normal file
64
TUIKit/components/common/Slider/index.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div
|
||||
class="slider-box"
|
||||
:class="[isSliderOpen && 'slider-open']"
|
||||
@click="toggleSlider"
|
||||
>
|
||||
<span class="slider-block" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watchEffect } from '../../../adapter-vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const isSliderOpen = ref(false);
|
||||
|
||||
const emits = defineEmits(['change']);
|
||||
|
||||
watchEffect(() => {
|
||||
isSliderOpen.value = props.open;
|
||||
});
|
||||
|
||||
const toggleSlider = () => {
|
||||
isSliderOpen.value = !isSliderOpen.value;
|
||||
emits('change', isSliderOpen.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../../assets/styles/common";
|
||||
|
||||
.slider {
|
||||
&-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: #e1e1e3;
|
||||
}
|
||||
|
||||
&-open {
|
||||
background: #006eff !important;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&-block {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
margin: 0 2px;
|
||||
background: #fff;
|
||||
border: 0 solid rgba(0, 0, 0, 0.85);
|
||||
box-shadow: 0 2px 4px 0 #d1d1d1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
2
TUIKit/components/common/SwitchBar/index.ts
Normal file
2
TUIKit/components/common/SwitchBar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import SwitchBar from './index.vue';
|
||||
export default SwitchBar;
|
||||
74
TUIKit/components/common/SwitchBar/index.vue
Normal file
74
TUIKit/components/common/SwitchBar/index.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<!-- This component only supports external changes to prop value to change the switch state, to avoid the situation where the switchbar display state does not match the expected state due to internal asynchronous problems -->
|
||||
<div :class="['tui-switch', value ? 'tui-switch-checked' : 'tui-switch-no-checked']" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
value: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.tui-switch {
|
||||
margin: 2px 5px;
|
||||
width: 48px;
|
||||
height: 30px;
|
||||
position: relative;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: #dfdfdf 0 0 0 0 inset;
|
||||
border-radius: 20px;
|
||||
background-clip: content-box;
|
||||
display: inline-block;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
&-checked {
|
||||
background-color: #007aff;
|
||||
transition: 0.6s;
|
||||
|
||||
&::before {
|
||||
transition: 0.3s;
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
&:active::before {
|
||||
width: 28px;
|
||||
left: 16px;
|
||||
transition: 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
&-no-checked {
|
||||
background-color: #dcdfe6;
|
||||
transition: 0.6s;
|
||||
|
||||
&::before {
|
||||
left: 2px;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
&:active::before {
|
||||
width: 28px;
|
||||
transition: 0.3s;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
36
TUIKit/components/common/Toast/index.ts
Normal file
36
TUIKit/components/common/Toast/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { TUIGlobal } from '@tencentcloud/universal-api';
|
||||
import TOAST_TYPE from './type';
|
||||
|
||||
interface IToast {
|
||||
message: string;
|
||||
type?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const Toast = (options: IToast): void => {
|
||||
TUIGlobal.showToast({
|
||||
title: options.message || 'Toast',
|
||||
duration: options.duration || 1500,
|
||||
icon: handleIconType(options.type),
|
||||
});
|
||||
};
|
||||
|
||||
const handleIconType = (type: string | undefined) => {
|
||||
if (!type) {
|
||||
return 'none';
|
||||
}
|
||||
switch (type) {
|
||||
case TOAST_TYPE.ERROR:
|
||||
return 'none';
|
||||
case TOAST_TYPE.WARNING:
|
||||
return 'none';
|
||||
case TOAST_TYPE.SUCCESS:
|
||||
return 'success';
|
||||
case TOAST_TYPE.NORMAL:
|
||||
return 'none';
|
||||
default:
|
||||
return 'none';
|
||||
}
|
||||
};
|
||||
|
||||
export { Toast, TOAST_TYPE };
|
||||
8
TUIKit/components/common/Toast/type.ts
Normal file
8
TUIKit/components/common/Toast/type.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
const TOAST_TYPE = {
|
||||
SUCCESS: 'success',
|
||||
WARNING: 'warning',
|
||||
ERROR: 'error',
|
||||
NORMAL: 'normal',
|
||||
};
|
||||
|
||||
export default TOAST_TYPE;
|
||||
2
TUIKit/components/common/Transfer/index.ts
Normal file
2
TUIKit/components/common/Transfer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import Transfer from './index.vue';
|
||||
export default Transfer;
|
||||
332
TUIKit/components/common/Transfer/index.vue
Normal file
332
TUIKit/components/common/Transfer/index.vue
Normal file
@@ -0,0 +1,332 @@
|
||||
<template>
|
||||
<div
|
||||
class="transfer"
|
||||
:class="[!isPC ? 'transfer-h5' : '', isWeChat ? 'transfer-h5-wechat' : '']"
|
||||
>
|
||||
<header
|
||||
v-if="!isPC"
|
||||
class="transfer-header transfer-h5-header"
|
||||
>
|
||||
<div
|
||||
v-if="!props.isHiddenBackIcon"
|
||||
@click="cancel"
|
||||
>
|
||||
<Icon
|
||||
class="icon"
|
||||
:file="backIcon"
|
||||
:width="'18px'"
|
||||
:height="'18px'"
|
||||
/>
|
||||
</div>
|
||||
<span class="title">{{ transferTitle }}</span>
|
||||
<span class="space" />
|
||||
</header>
|
||||
<main class="main">
|
||||
<div class="left">
|
||||
<header class="transfer-header">
|
||||
<!-- PC triggers @keyup.enter -->
|
||||
<input
|
||||
v-if="isPC && isTransferSearch"
|
||||
type="text"
|
||||
:value="searchValue"
|
||||
:placeholder="TUITranslateService.t('component.请输入userID')"
|
||||
enterkeyhint="search"
|
||||
:class="[isUniFrameWork ? 'left-uniapp-input' : '']"
|
||||
@keyup.enter="handleInput"
|
||||
>
|
||||
<!-- not PC triggers blur -->
|
||||
<input
|
||||
v-if="!isPC && isTransferSearch"
|
||||
type="text"
|
||||
:placeholder="TUITranslateService.t('component.请输入userID')"
|
||||
enterkeyhint="search"
|
||||
:value="searchValue"
|
||||
:class="[isUniFrameWork ? 'left-uniapp-input' : '']"
|
||||
@blur="handleInput"
|
||||
@confirm="handleInput"
|
||||
>
|
||||
</header>
|
||||
<main class="transfer-left-main">
|
||||
<ul class="transfer-list">
|
||||
<li
|
||||
v-if="optional.length > 1 && !isRadio"
|
||||
class="transfer-list-item"
|
||||
@click="selectedAll"
|
||||
>
|
||||
<Icon
|
||||
v-if="transferSelectedList.length === optional.length"
|
||||
:file="selectedIcon"
|
||||
:width="'18px'"
|
||||
:height="'18px'"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="icon-unselected"
|
||||
/>
|
||||
<span class="select-all">{{
|
||||
TUITranslateService.t("component.全选")
|
||||
}}</span>
|
||||
</li>
|
||||
<li
|
||||
v-for="item in transferList"
|
||||
:key="item.userID"
|
||||
class="transfer-list-item"
|
||||
@click="selected(item)"
|
||||
>
|
||||
<Icon
|
||||
v-if="transferSelectedList.indexOf(item) > -1"
|
||||
:file="selectedIcon"
|
||||
:class="[item.isDisabled && 'disabled']"
|
||||
:width="'18px'"
|
||||
:height="'18px'"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
:class="[item.isDisabled && 'disabled', 'icon-unselected']"
|
||||
/>
|
||||
<template v-if="!isTransferCustomItem">
|
||||
<img
|
||||
class="avatar"
|
||||
:src="
|
||||
item.avatar ||
|
||||
'https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_21.png'
|
||||
"
|
||||
onerror="this.onerror=null;this.src='https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_21.png'"
|
||||
>
|
||||
<span class="name">{{ item.nick || item.userID }}</span>
|
||||
<span v-if="item.isDisabled">({{ TUITranslateService.t("component.已在群中") }})</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot
|
||||
name="left"
|
||||
:data="item"
|
||||
/>
|
||||
</template>
|
||||
</li>
|
||||
<li
|
||||
v-if="transferTotal > transferList.length"
|
||||
class="transfer-list-item more"
|
||||
@click="getMore"
|
||||
>
|
||||
{{ TUITranslateService.t("component.查看更多") }}
|
||||
</li>
|
||||
</ul>
|
||||
</main>
|
||||
</div>
|
||||
<div class="right">
|
||||
<header
|
||||
v-if="isPC"
|
||||
class="transfer-header"
|
||||
>
|
||||
{{ transferTitle }}
|
||||
</header>
|
||||
<ul
|
||||
v-if="resultShow"
|
||||
class="transfer-list"
|
||||
>
|
||||
<p
|
||||
v-if="transferSelectedList.length > 0 && isPC"
|
||||
class="transfer-text"
|
||||
>
|
||||
{{ TUITranslateService.t("component.已选中")
|
||||
}}{{ transferSelectedList.length
|
||||
}}{{ TUITranslateService.t("component.人") }}
|
||||
</p>
|
||||
<li
|
||||
v-for="(item, index) in transferSelectedList"
|
||||
:key="index"
|
||||
class="transfer-list-item space-between"
|
||||
>
|
||||
<aside class="transfer-list-item-content">
|
||||
<template v-if="!isTransferCustomItem">
|
||||
<img
|
||||
class="avatar"
|
||||
:src="
|
||||
item.avatar ||
|
||||
'https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_21.png'
|
||||
"
|
||||
onerror="this.onerror=null;this.src='https://web.sdk.qcloud.com/component/TUIKit/assets/avatar_21.png'"
|
||||
>
|
||||
<span
|
||||
v-if="isPC"
|
||||
class="name"
|
||||
>{{ item.nick || item.userID }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot
|
||||
name="right"
|
||||
:data="item"
|
||||
/>
|
||||
</template>
|
||||
</aside>
|
||||
<span
|
||||
v-if="isPC"
|
||||
@click="selected(item)"
|
||||
>
|
||||
<Icon
|
||||
:file="cancelIcon"
|
||||
:width="'18px'"
|
||||
:height="'18px'"
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<footer class="transfer-right-footer">
|
||||
<button
|
||||
class="btn btn-cancel"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ TUITranslateService.t("component.取消") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="transferSelectedList.length > 0"
|
||||
class="btn"
|
||||
@click="submit"
|
||||
>
|
||||
{{ TUITranslateService.t("component.完成") }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-no"
|
||||
@click="submit"
|
||||
>
|
||||
{{ TUITranslateService.t("component.完成") }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watchEffect, computed } from '../../../adapter-vue';
|
||||
import { TUITranslateService } from '@tencentcloud/chat-uikit-engine';
|
||||
import { ITransferListItem } from '../../../interface';
|
||||
import Icon from '../Icon.vue';
|
||||
import selectedIcon from '../../../assets/icon/selected.svg';
|
||||
import backIcon from '../../../assets/icon/back.svg';
|
||||
import cancelIcon from '../../../assets/icon/cancel.svg';
|
||||
import { isPC, isUniFrameWork, isWeChat } from '../../../utils/env';
|
||||
|
||||
const props = defineProps({
|
||||
list: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isSearch: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isRadio: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isCustomItem: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
resultShow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
isHiddenBackIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const transferList = ref<ITransferListItem[]>([]);
|
||||
const transferTotal = ref<number>(0);
|
||||
const transferSelectedList = ref<ITransferListItem[]>([]);
|
||||
const isTransferSearch = ref(true);
|
||||
const isTransferCustomItem = ref(false);
|
||||
const transferTitle = ref('');
|
||||
const searchValue = ref('');
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.isCustomItem) {
|
||||
for (let index = 0; index < props.list.length; index++) {
|
||||
if (
|
||||
(props.list[index] as any).conversationID.indexOf('@TIM#SYSTEM') > -1
|
||||
) {
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
props.list.splice(index, 1);
|
||||
}
|
||||
transferList.value = props.list as ITransferListItem[];
|
||||
}
|
||||
} else {
|
||||
transferList.value = props.list as ITransferListItem[];
|
||||
}
|
||||
transferTotal.value = props.total ? props.total : props.list.length;
|
||||
transferSelectedList.value = (props.selectedList && props.selectedList.length > 0 ? props.selectedList : transferSelectedList.value) as any;
|
||||
isTransferSearch.value = props.isSearch;
|
||||
isTransferCustomItem.value = props.isCustomItem;
|
||||
transferTitle.value = props.title;
|
||||
});
|
||||
|
||||
const emit = defineEmits(['search', 'submit', 'cancel', 'getMore']);
|
||||
|
||||
const optional = computed(() =>
|
||||
transferList.value.filter((item: any) => !item.isDisabled),
|
||||
);
|
||||
|
||||
const handleInput = (e: any) => {
|
||||
searchValue.value = e.target.value || e.detail.value;
|
||||
emit('search', searchValue.value);
|
||||
};
|
||||
const selected = (item: any) => {
|
||||
if (item.isDisabled) {
|
||||
return;
|
||||
}
|
||||
let list: ITransferListItem[] = transferSelectedList.value;
|
||||
const index: number = list.indexOf(item);
|
||||
if (index > -1) {
|
||||
return transferSelectedList.value.splice(index, 1);
|
||||
}
|
||||
if (props.isRadio) {
|
||||
list = [];
|
||||
}
|
||||
list.push(item);
|
||||
transferSelectedList.value = list;
|
||||
};
|
||||
|
||||
const selectedAll = () => {
|
||||
if (transferSelectedList.value.length === optional.value.length) {
|
||||
transferSelectedList.value = [];
|
||||
} else {
|
||||
transferSelectedList.value = [...optional.value];
|
||||
}
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
emit('submit', transferSelectedList.value);
|
||||
searchValue.value = '';
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emit('cancel');
|
||||
searchValue.value = '';
|
||||
};
|
||||
|
||||
const getMore = () => {
|
||||
emit('getMore');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped src="./style/transfer.scss"></style>
|
||||
68
TUIKit/components/common/Transfer/style/color.scss
Normal file
68
TUIKit/components/common/Transfer/style/color.scss
Normal file
@@ -0,0 +1,68 @@
|
||||
.main {
|
||||
background: #FFF;
|
||||
border: 1px solid #E0E0E0;
|
||||
box-shadow: 0 -4px 12px 0 rgba(0, 0, 0, 0.06);
|
||||
|
||||
.left {
|
||||
border-right: 1px solid #E8E8E9;
|
||||
}
|
||||
|
||||
.transfer-header {
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
letter-spacing: 0;
|
||||
|
||||
input {
|
||||
background: #FFF;
|
||||
border: 1px solid #DEE0E3;
|
||||
font-weight: 500;
|
||||
color: #8F959E;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.transfer-list {
|
||||
.transfer-text {
|
||||
font-weight: 500;
|
||||
color: #8F959E;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
&-item {
|
||||
.disabled {
|
||||
background: #eee;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #3370FF;
|
||||
border: 0 solid #2F80ED;
|
||||
font-weight: 400;
|
||||
color: #FFF;
|
||||
|
||||
&-cancel {
|
||||
background: #FFF;
|
||||
border: 1px solid #DDD;
|
||||
color: #828282;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-no {
|
||||
background: #e8e8e9;
|
||||
border: 1px solid #DDD;
|
||||
font-weight: 400;
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
.transfer-h5-header {
|
||||
background: #FFF;
|
||||
|
||||
.title {
|
||||
font-family: PingFangSC-Medium;
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
93
TUIKit/components/common/Transfer/style/h5.scss
Normal file
93
TUIKit/components/common/Transfer/style/h5.scss
Normal file
@@ -0,0 +1,93 @@
|
||||
.transfer-h5 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&-wechat {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
&-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
padding: 16px 18px;
|
||||
|
||||
.space, .icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
max-height: calc(100% - 50px);
|
||||
padding: 0;
|
||||
|
||||
.avatar {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.left {
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
border: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.transfer-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding: 0 18px;
|
||||
|
||||
input {
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&-uniapp-input {
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
flex: 0;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
box-shadow: inset 0 1px 0 0 #EEE;
|
||||
padding: 8px 18px ;
|
||||
|
||||
.transfer-list {
|
||||
flex-direction: row;
|
||||
width: 0;
|
||||
|
||||
&-item {
|
||||
&-content {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.transfer-right-footer {
|
||||
padding: 6px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.btn {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
TUIKit/components/common/Transfer/style/transfer.scss
Normal file
13
TUIKit/components/common/Transfer/style/transfer.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
@import '../../../../assets/styles/common';
|
||||
@import "./color";
|
||||
@import "./web";
|
||||
@import "./h5";
|
||||
|
||||
.icon-unselected {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #FFF;
|
||||
border: 1px solid #DDD;
|
||||
border-radius: 11px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
141
TUIKit/components/common/Transfer/style/web.scss
Normal file
141
TUIKit/components/common/Transfer/style/web.scss
Normal file
@@ -0,0 +1,141 @@
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main {
|
||||
box-sizing: border-box;
|
||||
width: 620px;
|
||||
height: 394px;
|
||||
display: flex;
|
||||
border-radius: 8px;
|
||||
padding: 20px 0;
|
||||
|
||||
.transfer-header {
|
||||
font-size: 14px;
|
||||
line-height: 14px;
|
||||
padding-bottom: 20px;
|
||||
|
||||
input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
border-radius: 30px;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.transfer-list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.transfer-text {
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
&-item {
|
||||
padding: 6px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
|
||||
&-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin: 0 5px 0 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.name {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
padding: 0 20px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.transfer-right-footer {
|
||||
align-self: flex-end;
|
||||
|
||||
.btn-cancel {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.transfer-list {
|
||||
padding-right: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.left {
|
||||
flex: 1;
|
||||
overflow-y: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.transfer-header {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.transfer-left-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.btn {
|
||||
padding: 4px 28px;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn-no {
|
||||
padding: 4px 28px;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.space-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.select-all {
|
||||
padding-left: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
Reference in New Issue
Block a user