update AIChart, customer followup, media selector
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div v-bind="$attrs" class="media-selector">
|
||||
<div class="t-form__label" :class="{ required }">
|
||||
<slot name="title">
|
||||
<span v-text="title" />
|
||||
</slot>
|
||||
</div>
|
||||
<div class="photo-shell" :class="{ 'without-add': !showAdd }">
|
||||
<div class="items">
|
||||
<div v-if="!displayMediaList.length" class="empty" v-text="emptyText" />
|
||||
<media-selector-item
|
||||
v-for="(item, index) in displayMediaList"
|
||||
:key="`${item.url}-${item.attachType}-${index}`"
|
||||
:item="item"
|
||||
:removable="hasRemovablePhotos"
|
||||
:show-remove="showRemove === true"
|
||||
:show-type-label="showTypeLabel === true"
|
||||
@preview="openMediaViewer(index)"
|
||||
@remove="removePhoto(index)"
|
||||
/>
|
||||
</div>
|
||||
<media-selector-add
|
||||
v-if="showAdd"
|
||||
:add-actions="props.addActions ?? []"
|
||||
:add-text="props.addText ?? '选择文件'"
|
||||
:busy="isBusy"
|
||||
:upload-enabled="!!props.upload"
|
||||
@add="emit('add')"
|
||||
@capture-photo="emit('capture-photo')"
|
||||
@capture-video="emit('capture-video')"
|
||||
@file-change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<media-selector-viewer
|
||||
v-model:visible="photoViewerVisible"
|
||||
:images="photoViewerImages"
|
||||
:items="photoViewerItems"
|
||||
:show-actions="showViewerActions === true"
|
||||
:submitting="isSubmitting"
|
||||
@change-type="changePhotoType"
|
||||
@remove="removePhotoBySourceIndex"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MediaSelectorAdd from "./MediaSelectorAdd.vue";
|
||||
import MediaSelectorItem from "./MediaSelectorItem.vue";
|
||||
import MediaSelectorViewer from "./MediaSelectorViewer.vue";
|
||||
import type { MediaSelectorAddAction, MediaSelectorViewerItem } from "./types";
|
||||
import { getMediaPreviewURL, normalizePhotoItem } from "./utils";
|
||||
import type { PhotoItem } from "@/types/Photo.ts";
|
||||
import { Message } from "@/utils/message.ts";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
});
|
||||
|
||||
interface Props {
|
||||
addActions?: readonly MediaSelectorAddAction[];
|
||||
addText?: string;
|
||||
editMode?: boolean;
|
||||
emptyText?: string;
|
||||
fallbackImage?: string;
|
||||
loading?: boolean;
|
||||
modelValue: string[];
|
||||
photoItems?: readonly PhotoItem[];
|
||||
required?: boolean;
|
||||
upload?: (fileList: File[]) => Promise<PhotoItem[]>;
|
||||
showViewerActions?: boolean;
|
||||
showAdd?: boolean;
|
||||
showRemove?: boolean;
|
||||
showTypeLabel?: boolean;
|
||||
submit?: (photoItems: PhotoItem[]) => Promise<void>;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
addActions: () => [],
|
||||
title: "媒体列表",
|
||||
addText: "选择文件",
|
||||
editMode: false,
|
||||
emptyText: "暂无照片",
|
||||
loading: false,
|
||||
required: false,
|
||||
showAdd: true,
|
||||
showRemove: true,
|
||||
showTypeLabel: true,
|
||||
showViewerActions: true
|
||||
});
|
||||
|
||||
type Emits = {
|
||||
(event: string): void;
|
||||
(event: "update:modelValue", value: string[]): void;
|
||||
(event: "update:photoItems", value: PhotoItem[]): void;
|
||||
};
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const photoViewerVisible = ref(false);
|
||||
const photoViewerImages = ref<string[]>([]);
|
||||
const photoViewerItems = ref<MediaSelectorViewerItem[]>([]);
|
||||
const isSubmitting = ref(false);
|
||||
const isUploading = ref(false);
|
||||
const isBusy = computed(() => props.loading || isSubmitting.value || isUploading.value);
|
||||
|
||||
const displayMediaList = computed<PhotoItem[]>(() => {
|
||||
if (props.photoItems?.length) {
|
||||
return props.photoItems.map(normalizePhotoItem);
|
||||
}
|
||||
if (props.modelValue.length) {
|
||||
return props.modelValue.map(url => ({ url, attachType: "PHOTO" }));
|
||||
}
|
||||
|
||||
return props.fallbackImage ? [{ url: props.fallbackImage, attachType: "PHOTO" }] : [];
|
||||
});
|
||||
const hasRemovablePhotos = computed(() => !!props.photoItems?.length || !!props.modelValue.length);
|
||||
|
||||
async function handleFileChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const fileList = Array.from(input.files || []);
|
||||
if (!fileList.length || !props.upload) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUploading.value = true;
|
||||
try {
|
||||
const uploadedPhotoItems = await props.upload(fileList);
|
||||
if (!uploadedPhotoItems.length) {
|
||||
return;
|
||||
}
|
||||
const nextPhotoItems = [...displayMediaList.value, ...uploadedPhotoItems.map((item, index) => ({
|
||||
...item,
|
||||
mimeType: item.mimeType || item.attachment?.mimeType || fileList[index]?.type
|
||||
}))];
|
||||
emit("update:photoItems", nextPhotoItems);
|
||||
emit("update:modelValue", nextPhotoItems.map(item => item.url));
|
||||
} catch {
|
||||
Message.error("媒体上传失败");
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function removePhoto(index: number): Promise<void> {
|
||||
if (index < 0 || displayMediaList.value.length <= index) {
|
||||
return;
|
||||
}
|
||||
|
||||
await applyPhotoItems(displayMediaList.value.filter((_, itemIndex) => itemIndex !== index));
|
||||
}
|
||||
|
||||
function openMediaViewer(index: number): void {
|
||||
openPhotoViewer(index);
|
||||
}
|
||||
|
||||
function openPhotoViewer(index: number): void {
|
||||
const viewerItems = displayMediaList.value.map((item, sourceIndex) => ({ item, sourceIndex }));
|
||||
const imageIndex = viewerItems.findIndex(({ sourceIndex }) => sourceIndex === index);
|
||||
if (imageIndex < 0) {
|
||||
return;
|
||||
}
|
||||
const rotatedItems = [...viewerItems.slice(imageIndex), ...viewerItems.slice(0, imageIndex)];
|
||||
photoViewerItems.value = rotatedItems.map(({ item, sourceIndex }) => ({ ...item, sourceIndex }));
|
||||
photoViewerImages.value = photoViewerItems.value.map(getMediaPreviewURL);
|
||||
photoViewerVisible.value = true;
|
||||
}
|
||||
|
||||
async function changePhotoType(sourceIndex: number, attachType: string): Promise<void> {
|
||||
const currentItem = displayMediaList.value[sourceIndex];
|
||||
if (!currentItem || currentItem.attachType === attachType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPhotoItems = displayMediaList.value.map((item, index) => index === sourceIndex ? {
|
||||
...item,
|
||||
attachType
|
||||
} : item);
|
||||
await applyPhotoItems(nextPhotoItems);
|
||||
}
|
||||
|
||||
async function removePhotoBySourceIndex(sourceIndex: number): Promise<void> {
|
||||
if (!displayMediaList.value[sourceIndex]) {
|
||||
return;
|
||||
}
|
||||
|
||||
await applyPhotoItems(displayMediaList.value.filter((_, index) => index !== sourceIndex));
|
||||
}
|
||||
|
||||
async function applyPhotoItems(nextPhotoItems: PhotoItem[]): Promise<void> {
|
||||
if (isSubmitting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.editMode && !props.submit) {
|
||||
Message.warning("展示模式未配置媒体保存方法");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.editMode) {
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await props.submit!(nextPhotoItems);
|
||||
} catch {
|
||||
Message.error("媒体修改失败");
|
||||
return;
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
emit("update:photoItems", nextPhotoItems);
|
||||
emit("update:modelValue", nextPhotoItems.map(item => item.url));
|
||||
photoViewerVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.media-selector {
|
||||
gap: .375rem;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
|
||||
.photo-shell {
|
||||
display: grid;
|
||||
gap: .75rem;
|
||||
grid-template-columns: minmax(0, 1fr) 5.5rem;
|
||||
align-items: start;
|
||||
|
||||
&.without-add {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.items {
|
||||
gap: .75rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
|
||||
.empty {
|
||||
width: 100%;
|
||||
color: #52607a;
|
||||
display: flex;
|
||||
padding: 1rem 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8fbff;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.t-form__label {
|
||||
&.required::before {
|
||||
color: var(--td-error-color, #e34d59);
|
||||
content: "*";
|
||||
margin-right: .25rem;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.media-selector .empty {
|
||||
color: #e2e8f0;
|
||||
background: rgba(15, 23, 42, .72);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="add-trigger">
|
||||
<t-popover
|
||||
v-model:visible="capturePopoverVisible"
|
||||
placement="left"
|
||||
theme="light"
|
||||
>
|
||||
<t-button
|
||||
class="capture"
|
||||
theme="primary"
|
||||
variant="dashed"
|
||||
:loading="busy"
|
||||
:disabled="busy"
|
||||
>
|
||||
<template #icon>
|
||||
<t-icon name="camera" />
|
||||
</template>
|
||||
拍摄
|
||||
</t-button>
|
||||
<template #content>
|
||||
<div class="add-actions">
|
||||
<t-button
|
||||
v-for="(action, index) in captureActionList"
|
||||
:key="`${action.emit}-${index}`"
|
||||
variant="text"
|
||||
:theme="action.theme"
|
||||
class="add-action"
|
||||
:disabled="busy"
|
||||
@click="handleCaptureAction(action)"
|
||||
>
|
||||
<template #icon v-if="action.icon">
|
||||
<t-icon :name="action.icon" />
|
||||
</template>
|
||||
<span v-text="action.text" />
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
</t-popover>
|
||||
<t-button
|
||||
class="file"
|
||||
theme="primary"
|
||||
variant="dashed"
|
||||
:loading="busy"
|
||||
:disabled="busy"
|
||||
@click="handleFileAction"
|
||||
>
|
||||
<template #icon>
|
||||
<t-icon name="file-attachment" />
|
||||
</template>
|
||||
<span v-text="addText" />
|
||||
</t-button>
|
||||
</div>
|
||||
<input
|
||||
v-if="uploadEnabled"
|
||||
ref="fileInput"
|
||||
class="file-input"
|
||||
type="file"
|
||||
accept="*/*"
|
||||
multiple
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
<input
|
||||
v-if="uploadEnabled"
|
||||
ref="photoFileInput"
|
||||
class="file-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
<input
|
||||
v-if="uploadEnabled"
|
||||
ref="videoFileInput"
|
||||
class="file-input"
|
||||
type="file"
|
||||
accept="video/*"
|
||||
capture="environment"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MediaSelectorAddAction } from "./types";
|
||||
|
||||
interface Props {
|
||||
addActions: readonly MediaSelectorAddAction[];
|
||||
addText: string;
|
||||
busy: boolean;
|
||||
uploadEnabled: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
(event: "add"): void;
|
||||
(event: "capture-photo"): void;
|
||||
(event: "capture-video"): void;
|
||||
(event: "file-change", value: Event): void;
|
||||
}>();
|
||||
const emitExternal = (event: string): void => {
|
||||
(emit as unknown as (eventName: string) => void)(event);
|
||||
};
|
||||
|
||||
const capturePopoverVisible = ref(false);
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
const photoFileInput = ref<HTMLInputElement>();
|
||||
const videoFileInput = ref<HTMLInputElement>();
|
||||
const captureActionList = computed<readonly MediaSelectorAddAction[]>(() => [{
|
||||
icon: "camera",
|
||||
emit: "capture-photo",
|
||||
text: "拍照片",
|
||||
theme: "primary"
|
||||
}, {
|
||||
icon: "video-camera-1",
|
||||
emit: "capture-video",
|
||||
text: "录视频",
|
||||
theme: "primary"
|
||||
}, ...props.addActions]);
|
||||
|
||||
function handleFileAction(): void {
|
||||
capturePopoverVisible.value = false;
|
||||
if (props.uploadEnabled) {
|
||||
fileInput.value?.click();
|
||||
return;
|
||||
}
|
||||
emit("add");
|
||||
}
|
||||
|
||||
function handleCaptureAction(action: MediaSelectorAddAction): void {
|
||||
capturePopoverVisible.value = false;
|
||||
if (action.emit === "capture-photo" && props.uploadEnabled) {
|
||||
photoFileInput.value?.click();
|
||||
return;
|
||||
}
|
||||
if (action.emit === "capture-video" && props.uploadEnabled) {
|
||||
videoFileInput.value?.click();
|
||||
return;
|
||||
}
|
||||
emitExternal(action.emit);
|
||||
}
|
||||
|
||||
function handleFileChange(event: Event): void {
|
||||
emit("file-change", event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.add-trigger {
|
||||
--td-popover-padding: 0 .5rem;
|
||||
gap: .375rem;
|
||||
width: 5.5rem;
|
||||
height: 5.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.t-popover__content) {
|
||||
border-radius: .875rem;
|
||||
}
|
||||
|
||||
.capture,
|
||||
.file {
|
||||
--td-button-border-radius: .875rem;
|
||||
|
||||
width: 5.5rem;
|
||||
height: 2.5625rem;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.add-actions {
|
||||
gap: .25rem;
|
||||
display: grid;
|
||||
padding: .5rem 0;
|
||||
|
||||
.add-action {
|
||||
--td-button-medium-height: 32px;
|
||||
--td-button-medium-padding-horizontal: 10px;
|
||||
|
||||
gap: .5rem;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
font-weight: unset;
|
||||
justify-content: start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.capture,
|
||||
.file {
|
||||
background: rgba(37, 99, 235, .16);
|
||||
border-color: rgba(96, 165, 250, .28);
|
||||
|
||||
span {
|
||||
color: #93c5fd;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<t-button variant="text" class="preview-trigger" @click="emit('preview')">
|
||||
<div v-if="isVideoItem(item)" class="video-thumbnail">
|
||||
<t-image
|
||||
v-if="getMediaThumbnailURL(item)"
|
||||
class="image"
|
||||
:src="getMediaThumbnailURL(item)"
|
||||
:alt="getAttachTypeText(item.attachType)"
|
||||
fit="cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="getMediaPreviewURL(item)"
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
/>
|
||||
<span class="video-play" aria-hidden="true">▶</span>
|
||||
</div>
|
||||
<t-image
|
||||
v-else
|
||||
class="image"
|
||||
:src="item.url"
|
||||
:alt="getAttachTypeText(item.attachType)"
|
||||
fit="cover"
|
||||
/>
|
||||
</t-button>
|
||||
<div
|
||||
v-if="showTypeLabel"
|
||||
class="type-label white glass-black clip-text"
|
||||
v-text="getAttachTypeText(item.attachType)"
|
||||
/>
|
||||
<t-button
|
||||
v-if="showRemove && removable"
|
||||
theme="danger"
|
||||
class="remove"
|
||||
shape="circle"
|
||||
size="extra-small"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<t-icon name="close" size="1rem" />
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PhotoItem } from "@/types/Photo.ts";
|
||||
import {
|
||||
getAttachTypeText,
|
||||
getMediaPreviewURL,
|
||||
getMediaThumbnailURL,
|
||||
isVideoItem
|
||||
} from "./utils";
|
||||
|
||||
interface Props {
|
||||
item: PhotoItem;
|
||||
removable: boolean;
|
||||
showRemove: boolean;
|
||||
showTypeLabel: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
preview: [];
|
||||
remove: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.item {
|
||||
width: 5.5rem;
|
||||
height: 5.5rem;
|
||||
flex: 0 0 5.5rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
|
||||
.preview-trigger {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
|
||||
.image {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: #0f172a;
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-play {
|
||||
inset: 50% auto auto 50%;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
color: #fff;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
font-size: 1.125rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(15, 23, 42, .68);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.type-label {
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
padding: .125rem .5rem;
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
font-size: .875rem;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
border-radius: 0 0 1rem 1rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.remove {
|
||||
top: .375rem;
|
||||
right: .375rem;
|
||||
position: absolute;
|
||||
|
||||
:deep(.t-button__content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<t-image-viewer
|
||||
v-model:visible="viewerVisible"
|
||||
:images="images"
|
||||
show-index
|
||||
@close="handleClose"
|
||||
@index-change="handleIndexChange"
|
||||
>
|
||||
<template #image="params">
|
||||
<div
|
||||
v-if="isVideoViewerItem(params.index)"
|
||||
:class="[params.className, 'viewer-video-wrap']"
|
||||
:style="params.style"
|
||||
@click.stop
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
@touchstart.stop
|
||||
@touchmove.stop
|
||||
@touchend.stop
|
||||
>
|
||||
<video
|
||||
:src="getViewerMediaURL(params.index)"
|
||||
class="viewer-video"
|
||||
controls
|
||||
playsinline
|
||||
preload="metadata"
|
||||
@loadedmetadata="handleViewerVideoLoaded($event, params)"
|
||||
@transitionstart="params.onTransitionstart"
|
||||
@transitionend="params.onTransitionend"
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
v-else
|
||||
:src="params.src"
|
||||
:class="params.className"
|
||||
:style="params.style"
|
||||
:alt="getViewerAlt(params.index)"
|
||||
@load="params.onLoad"
|
||||
@transitionstart="params.onTransitionstart"
|
||||
@transitionend="params.onTransitionend"
|
||||
/>
|
||||
</template>
|
||||
<template #delete-btn>
|
||||
<div v-if="showActions" class="viewer-action" @click.stop>
|
||||
<t-popover
|
||||
v-model:visible="photoActionVisible"
|
||||
placement="bottom"
|
||||
theme="light"
|
||||
:show-arrow="false"
|
||||
>
|
||||
<t-button class="white" variant="text">
|
||||
<t-icon name="ellipsis" />
|
||||
</t-button>
|
||||
<template #content>
|
||||
<div class="actions" @click.stop>
|
||||
<div class="title">修改类型</div>
|
||||
<div class="type">
|
||||
<t-button
|
||||
v-for="item in attachTypeOptions"
|
||||
:key="item.value"
|
||||
:theme="item.value === currentPhotoItem?.attachType ? 'primary' : 'default'"
|
||||
variant="text"
|
||||
size="extra-small"
|
||||
:disabled="submitting"
|
||||
@click="changePhotoType(item.value)"
|
||||
>
|
||||
<span v-text="item.text" />
|
||||
</t-button>
|
||||
</div>
|
||||
<t-divider />
|
||||
<t-button
|
||||
class="button delete-action"
|
||||
theme="danger"
|
||||
size="extra-small"
|
||||
variant="text"
|
||||
:disabled="submitting"
|
||||
@click="removeCurrentPhoto"
|
||||
>
|
||||
<template #icon>
|
||||
<t-icon name="delete" />
|
||||
</template>
|
||||
删除
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
</t-popover>
|
||||
</div>
|
||||
</template>
|
||||
</t-image-viewer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ImageSlotParams } from "tdesign-mobile-vue";
|
||||
import type { MediaSelectorViewerItem } from "./types";
|
||||
import { getAttachTypeText, getMediaPreviewURL, isVideoItem } from "./utils";
|
||||
|
||||
interface Props {
|
||||
images: string[];
|
||||
items: readonly MediaSelectorViewerItem[];
|
||||
showActions: boolean;
|
||||
submitting: boolean;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
(event: "update:visible", value: boolean): void;
|
||||
(event: "change-type", sourceIndex: number, attachType: string): void;
|
||||
(event: "remove", sourceIndex: number): void;
|
||||
}>();
|
||||
|
||||
const viewerVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: value => emit("update:visible", value)
|
||||
});
|
||||
const viewerIndex = ref(0);
|
||||
const photoActionVisible = ref(false);
|
||||
const attachTypeOptions = [
|
||||
{ text: "照片", value: "PHOTO" },
|
||||
{ text: "积分卡", value: "POINT_CARD" },
|
||||
{ text: "其他文件", value: "OTHER" }
|
||||
] as const;
|
||||
const currentPhotoItem = computed(() => props.items[viewerIndex.value]);
|
||||
|
||||
watch(() => props.visible, value => {
|
||||
if (value) {
|
||||
viewerIndex.value = 0;
|
||||
photoActionVisible.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function handleClose(): void {
|
||||
photoActionVisible.value = false;
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
function handleIndexChange(index: number): void {
|
||||
viewerIndex.value = index;
|
||||
photoActionVisible.value = false;
|
||||
}
|
||||
|
||||
function isVideoViewerItem(index?: number): boolean {
|
||||
const item = getViewerItem(index);
|
||||
return !!item && isVideoItem(item);
|
||||
}
|
||||
|
||||
function getViewerItem(index?: number): MediaSelectorViewerItem | undefined {
|
||||
return index === undefined ? undefined : props.items[index];
|
||||
}
|
||||
|
||||
function getViewerMediaURL(index?: number): string {
|
||||
const item = getViewerItem(index);
|
||||
return item ? getMediaPreviewURL(item) : "";
|
||||
}
|
||||
|
||||
function getViewerAlt(index?: number): string {
|
||||
return getAttachTypeText(getViewerItem(index)?.attachType);
|
||||
}
|
||||
|
||||
function handleViewerVideoLoaded(event: Event, params: ImageSlotParams): void {
|
||||
const video = event.currentTarget as HTMLVideoElement;
|
||||
if (video.videoHeight > 0) {
|
||||
video.height = video.videoHeight;
|
||||
}
|
||||
params.onLoad?.(event);
|
||||
}
|
||||
|
||||
function changePhotoType(attachType: string): void {
|
||||
const currentItem = currentPhotoItem.value;
|
||||
photoActionVisible.value = false;
|
||||
if (!currentItem || currentItem.attachType === attachType) {
|
||||
return;
|
||||
}
|
||||
emit("change-type", currentItem.sourceIndex, attachType);
|
||||
}
|
||||
|
||||
function removeCurrentPhoto(): void {
|
||||
const currentItem = currentPhotoItem.value;
|
||||
photoActionVisible.value = false;
|
||||
if (currentItem) {
|
||||
emit("remove", currentItem.sourceIndex);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.viewer-action {
|
||||
color: #fff;
|
||||
margin-left: -1rem;
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
min-width: 8rem;
|
||||
flex-direction: column;
|
||||
|
||||
.title {
|
||||
padding: 0 .75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.type {
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
flex-direction: column;
|
||||
|
||||
.button {
|
||||
text-indent: 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
justify-content: start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.viewer-video-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewer-video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
background: #0f172a;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ButtonProps } from "tdesign-mobile-vue";
|
||||
import type { PhotoItem } from "@/types/Photo.ts";
|
||||
|
||||
export type MediaSelectorAddAction = {
|
||||
icon?: string;
|
||||
emit: string;
|
||||
text: string;
|
||||
theme?: ButtonProps["theme"];
|
||||
};
|
||||
|
||||
export type MediaSelectorViewerItem = PhotoItem & {
|
||||
sourceIndex: number;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { PhotoItem } from "@/types/Photo.ts";
|
||||
import { getAttachmentReadURL } from "@/utils/attachment.ts";
|
||||
|
||||
const ATTACH_TYPE_TEXT_MAP: Record<string, string> = {
|
||||
ADDITIONAL: "附加照片",
|
||||
PHOTO: "照片",
|
||||
POINT_CARD: "积分卡",
|
||||
OTHER: "其他文件"
|
||||
};
|
||||
|
||||
/** 缩略图宽度查询串,地址中出现即表示已带缩略图参数 */
|
||||
const THUMBNAIL_WIDTH_QUERY = "thumbWidth=";
|
||||
|
||||
/** 缩略图兜底尺寸(像素),用于地址未带缩略图参数但可定位附件的场景 */
|
||||
const FALLBACK_THUMBNAIL_SIZE = 128;
|
||||
|
||||
export function getAttachTypeText(attachType?: string): string {
|
||||
return ATTACH_TYPE_TEXT_MAP[attachType || "PHOTO"] || "其他文件";
|
||||
}
|
||||
|
||||
export function getMediaPreviewURL(item: PhotoItem): string {
|
||||
return item.previewUrl || item.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析媒体缩略图地址。
|
||||
*
|
||||
* 后端读附件接口只要带缩略图参数,视频附件同样返回该视频的缩略帧,
|
||||
* 因此视频在列表中统一按图片渲染;临时上传文件没有缩略图,返回空字符串由调用方降级。
|
||||
*/
|
||||
export function getMediaThumbnailURL(item: PhotoItem): string {
|
||||
if (item.url.includes(THUMBNAIL_WIDTH_QUERY)) {
|
||||
return item.url;
|
||||
}
|
||||
|
||||
const attachmentId = item.attachment?.id;
|
||||
return attachmentId ? getAttachmentReadURL(attachmentId, FALLBACK_THUMBNAIL_SIZE) : "";
|
||||
}
|
||||
|
||||
export function isVideoItem(item: PhotoItem): boolean {
|
||||
const mimeType = (item.mimeType || item.attachment?.mimeType || "").toLowerCase();
|
||||
if (mimeType.startsWith("video/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /\.(avi|m4v|mkv|mov|mp4|mpeg|mpg|ogv|webm|wmv)(?:$|[?#])/i.test(item.url);
|
||||
}
|
||||
|
||||
export function normalizePhotoItem(item: PhotoItem): PhotoItem {
|
||||
return {
|
||||
...item,
|
||||
attachType: item.attachType || item.attachment?.attachType || "PHOTO",
|
||||
mimeType: item.mimeType || item.attachment?.mimeType
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user