update AIChart, customer followup, media selector

This commit is contained in:
Timi
2026-09-18 17:38:55 +08:00
parent c6b398fa98
commit 57c4275a23
57 changed files with 3866 additions and 2071 deletions
@@ -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>