This commit is contained in:
pengxiaolong
2025-05-13 19:39:53 +08:00
parent 37da6765b8
commit c006a8e63d
1232 changed files with 96963 additions and 883 deletions

View 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>

View File

@@ -0,0 +1,3 @@
import BottomPopup from './index.vue';
export default BottomPopup;

View 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>

View 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;
}

View File

@@ -0,0 +1,3 @@
@import "../../../../assets/styles/common";
@import "./h5";
@import "./modal";

View File

@@ -0,0 +1,3 @@
.bottom-popup-modal {
background: rgba(0, 0, 0, 0.5);
}

View 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>

View 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;
}

View 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>

View File

@@ -0,0 +1,2 @@
import DatePicker from './index.vue';
export default DatePicker;

View 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>

View File

@@ -0,0 +1,3 @@
import Dialog from './index.vue';
export default Dialog;

View 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>

View 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;
}
}

View File

@@ -0,0 +1,4 @@
@import '../../../../assets/styles/common';
@import "./color";
@import "./web";
@import "./h5";

View 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;
}

View 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;
}
}

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,3 @@
import ImagePreviewer from './index.vue';
export default ImagePreviewer;

View 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>

View File

@@ -0,0 +1,2 @@
import Loading from './index.vue';
export default Loading;

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,3 @@
import SelectUser from './index.vue';
export default SelectUser;

View 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>

View 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>

View File

@@ -0,0 +1,2 @@
import SwitchBar from './index.vue';
export default SwitchBar;

View 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>

View 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 };

View File

@@ -0,0 +1,8 @@
const TOAST_TYPE = {
SUCCESS: 'success',
WARNING: 'warning',
ERROR: 'error',
NORMAL: 'normal',
};
export default TOAST_TYPE;

View File

@@ -0,0 +1,2 @@
import Transfer from './index.vue';
export default Transfer;

View 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>

View 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;
}
}

View 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;
}
}
}
}
}

View 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;
}

View 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;
}