|
|
@@ -0,0 +1,312 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import { h } from 'vue';
|
|
|
+import { useFileDialog } from '@vueuse/core';
|
|
|
+
|
|
|
+import { CloudUploadOutlined, DeleteOutlined, LinkOutlined, NumberOutlined, PlusOutlined } from '@ant-design/icons-vue';
|
|
|
+import { Sender } from 'ant-design-x-vue';
|
|
|
+import { theme as Theme } from 'ant-design-vue';
|
|
|
+
|
|
|
+import { useForm } from 'alova/client';
|
|
|
+import { analysisHealthMethod } from '@/request/api';
|
|
|
+
|
|
|
+import { Dialog, Toast } from '@/platform';
|
|
|
+import { blob2Base64, randomUUID } from '@/tools';
|
|
|
+import { useMessagesContext } from '@/modules/chat/composables';
|
|
|
+import { analysisHealthConfig, type AnalysisHealthValue } from '@/modules/chat/config';
|
|
|
+import type { MessageRendererEmits, MessageRendererProps } from '@/modules/chat/renderer/index.ts';
|
|
|
+import type { AnalysisHealthKey, AnalysisHealthModel } from '@/modules/chat/types';
|
|
|
+
|
|
|
+const { token } = Theme.useToken();
|
|
|
+
|
|
|
+const command = {
|
|
|
+ upload: '开始上传',
|
|
|
+ no: '不采集',
|
|
|
+} as const;
|
|
|
+
|
|
|
+defineOptions({ inheritAttrs: false });
|
|
|
+
|
|
|
+interface Props extends MessageRendererProps {
|
|
|
+ group?: Array<AnalysisHealthKey | AnalysisHealthValue>;
|
|
|
+ capture?: 'user' | 'environment'
|
|
|
+}
|
|
|
+
|
|
|
+type Emits = MessageRendererEmits;
|
|
|
+
|
|
|
+const { group = [], complete, capture = 'user' } = defineProps<Props>();
|
|
|
+const emits = defineEmits<Emits>();
|
|
|
+
|
|
|
+const open = defineModel('open', { default: false });
|
|
|
+const senderInstance = useTemplateRef<AntXSenderInstance>('sender-ref');
|
|
|
+
|
|
|
+const items = shallowRef<AnalysisHealthValue[]>([]);
|
|
|
+const index = ref(0);
|
|
|
+watchEffect(() => {
|
|
|
+ for (let item of group) {
|
|
|
+ if (typeof item === 'string') item = Object.assign({ key: item }, analysisHealthConfig[item]);
|
|
|
+ if (item) items.value.push(item);
|
|
|
+ }
|
|
|
+ triggerRef(items);
|
|
|
+ index.value = 0;
|
|
|
+});
|
|
|
+
|
|
|
+const pending = ref(false);
|
|
|
+watchEffect(() => {
|
|
|
+ pending.value = !complete;
|
|
|
+ if (complete) {
|
|
|
+ onTrigger(true);
|
|
|
+ nextTick(() => senderInstance.value?.focus({ cursor: 'end' }));
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+const { append, update } = useMessagesContext();
|
|
|
+
|
|
|
+const {
|
|
|
+ loading,
|
|
|
+ form: model,
|
|
|
+ send: analysis,
|
|
|
+} = useForm(analysisHealthMethod, {
|
|
|
+ initialForm: {} as AnalysisHealthModel,
|
|
|
+ initialData: {},
|
|
|
+})
|
|
|
+ .onSuccess(({ data }) => {
|
|
|
+ open.value = false;
|
|
|
+ const cover = items.value
|
|
|
+ .map((item) => {
|
|
|
+ const url = data.model?.[item.key]?.url;
|
|
|
+ return url ? `<img src="${url}" alt="${item.label}">` : void 0;
|
|
|
+ })
|
|
|
+ .filter(Boolean);
|
|
|
+
|
|
|
+ append({ role: 'user', content: `<div class="image-gather">${cover.join('')}</div>` });
|
|
|
+
|
|
|
+ let gather = [];
|
|
|
+ if (data.result?.tongue?.result) gather.push(`**舌象**:${data.result.tongue.result}`);
|
|
|
+ if (data.result?.face?.result) gather.push(`**面象**:${data.result.face.result}`);
|
|
|
+ if (gather.length > 1) { gather = gather.map(item => `- ${item}`); }
|
|
|
+
|
|
|
+ append({
|
|
|
+ role: 'chat',
|
|
|
+ content: gather.join('\n'),
|
|
|
+ onTypingComplete: () => {
|
|
|
+ emits('next');
|
|
|
+ },
|
|
|
+ });
|
|
|
+ })
|
|
|
+ .onError(({ error }) => {
|
|
|
+ onTrigger(true);
|
|
|
+ Toast.error(error.message || '请重试');
|
|
|
+ });
|
|
|
+
|
|
|
+const submittable = computed(() => items.value.every((item) => !item.required || model.value[item.key]?.thumbnail));
|
|
|
+const dirty = computed(() => items.value.some((item) => model.value[item.key]?.thumbnail));
|
|
|
+
|
|
|
+function onTrigger(key?: AnalysisHealthKey | boolean) {
|
|
|
+ if (key == null) open.value = !open.value;
|
|
|
+ else if (typeof key === 'boolean') open.value = key;
|
|
|
+ else {
|
|
|
+ open.value = true;
|
|
|
+ index.value = items.value.findIndex((item) => item.key === key);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const displayValue = computed(() => {
|
|
|
+ return Object.values(model.value).some((value) => value.url || value.thumbnail) ? command.upload : command.no;
|
|
|
+});
|
|
|
+
|
|
|
+const gatherLabel = computed(() => items.value.map((item) => item.label).join('、'));
|
|
|
+const senderProps = computed(() => {
|
|
|
+ return {
|
|
|
+ readOnly: true,
|
|
|
+ value: ' ',
|
|
|
+ loading: loading.value,
|
|
|
+ disabled: pending.value,
|
|
|
+ async onSubmit(value, confirm = true) {
|
|
|
+ value = value?.trim() || displayValue.value;
|
|
|
+ if (value === command.no) {
|
|
|
+ open.value = false;
|
|
|
+ if (
|
|
|
+ confirm &&
|
|
|
+ !(await Dialog.confirm({
|
|
|
+ title: '提示',
|
|
|
+ message: `确定不采集${gatherLabel.value}?`,
|
|
|
+ confirmButtonColor: token.value.red,
|
|
|
+ confirmButtonText: '不采集',
|
|
|
+ cancelButtonText: '采集',
|
|
|
+ showCancelButton: true,
|
|
|
+ }))
|
|
|
+ ) {
|
|
|
+ open.value = true;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ append({ role: 'user', content: value });
|
|
|
+ emits('next');
|
|
|
+ } else if (submittable.value) {
|
|
|
+ // open.value = false;
|
|
|
+ await analysis();
|
|
|
+ } else {
|
|
|
+ open.value = true;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ } satisfies AntXSenderProps;
|
|
|
+});
|
|
|
+
|
|
|
+const senderHeaderProps = computed(() => {
|
|
|
+ const gather = gatherLabel.value;
|
|
|
+ return {
|
|
|
+ open: open.value,
|
|
|
+ title: gather ? `请您按照提示要求,拍摄${gather}照片` : `请按照提示要求拍摄照片`,
|
|
|
+ tips: gather ? `注意下面是${gather}的拍摄标准哦!` : `注意下面是拍摄的标准哦!`,
|
|
|
+ closable: true,
|
|
|
+ onOpenChange(value: boolean) {
|
|
|
+ open.value = value;
|
|
|
+ },
|
|
|
+ };
|
|
|
+});
|
|
|
+
|
|
|
+const prefixProps = computed(() => {
|
|
|
+ return {
|
|
|
+ type: 'text',
|
|
|
+ icon: h(NumberOutlined),
|
|
|
+ disabled: pending.value,
|
|
|
+ } satisfies AntButtonProps;
|
|
|
+});
|
|
|
+
|
|
|
+let lastFileDialogKey: AnalysisHealthKey | void;
|
|
|
+const fileDialog = useFileDialog({ accept: 'image/*', capture });
|
|
|
+fileDialog.onChange(async (files) => {
|
|
|
+ const file = files?.[0];
|
|
|
+ if (!file || !lastFileDialogKey) return;
|
|
|
+ if (!file.type.startsWith('image')) return Toast.warning(`请选择图片格式`);
|
|
|
+ const thumbnail = await blob2Base64(file);
|
|
|
+ model.value[lastFileDialogKey] = { thumbnail, file };
|
|
|
+});
|
|
|
+
|
|
|
+const openFileDialog = (key: AnalysisHealthKey) => {
|
|
|
+ fileDialog.open({
|
|
|
+ reset: lastFileDialogKey !== key,
|
|
|
+ capture,
|
|
|
+ });
|
|
|
+ lastFileDialogKey = key;
|
|
|
+};
|
|
|
+const deleteFile = (key: AnalysisHealthKey) => {
|
|
|
+ Reflect.deleteProperty(model.value, key);
|
|
|
+ lastFileDialogKey = void 0;
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <Sender ref="sender-ref" v-bind="senderProps">
|
|
|
+ <template #header>
|
|
|
+ <Sender.Header v-bind="senderHeaderProps">
|
|
|
+ <a-spin :spinning="loading">
|
|
|
+ <a-flex vertical align="center" gap="small" :style="{ marginBlock: token.paddingLG }">
|
|
|
+ <a-typography-text style="margin: 0" :level="5" type="warning">{{ senderHeaderProps.tips }} </a-typography-text>
|
|
|
+ <a-row class="row" :gutter="8">
|
|
|
+ <a-col v-for="item in items" :key="item.key" :span="8">
|
|
|
+ <div class="square-container">
|
|
|
+ <div class="square">
|
|
|
+ <a-image :id="'e_' + item.key" :src="item.example" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <label :for="'e_' + item.key">{{ item.label }}举例</label>
|
|
|
+ </a-col>
|
|
|
+ </a-row>
|
|
|
+ <a-row class="row" style="margin-top: 20px" :gutter="8">
|
|
|
+ <a-col v-for="item in items" :key="item.key" :span="8">
|
|
|
+ <div class="square-container">
|
|
|
+ <a-badge class="square" :dot="item.required">
|
|
|
+ <div class="square">
|
|
|
+ <a-image v-if="model[item.key]?.url" :id="'url_' + item.key" :src="model[item.key].url" />
|
|
|
+ <a-image v-else-if="model[item.key]?.thumbnail" :id="'tmpl_' + item.key" :src="model[item.key].thumbnail" />
|
|
|
+ <a-button v-else class="upload-button" :id="'U_' + item.key" :disabled="loading" @click="openFileDialog(item.key)">
|
|
|
+ <PlusOutlined></PlusOutlined>
|
|
|
+ <div style="margin-top: 8px">{{ item.label }}照片</div>
|
|
|
+ </a-button>
|
|
|
+ </div>
|
|
|
+ </a-badge>
|
|
|
+ </div>
|
|
|
+ <div style="min-height: 19px; margin-top: 4px">
|
|
|
+ <label v-if="model[item.key]?.thumbnail" :for="'u_' + item.key" @click="openFileDialog(item.key)">
|
|
|
+ <a-space>
|
|
|
+ <span>{{ item.label }}</span>
|
|
|
+ <a-button type="text" size="small" :icon="h(DeleteOutlined)" :style="{ color: token.red }" :disabled="loading" @click.stop="deleteFile(item.key)"></a-button>
|
|
|
+ </a-space>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ </a-col>
|
|
|
+ </a-row>
|
|
|
+ <a-button :icon="h(CloudUploadOutlined)" :disabled="!submittable" :loading @click="senderProps.onSubmit(command.upload)">{{ command.upload }} </a-button>
|
|
|
+ </a-flex>
|
|
|
+ </a-spin>
|
|
|
+ </Sender.Header>
|
|
|
+ </template>
|
|
|
+ <template #prefix>
|
|
|
+ <a-space size="small">
|
|
|
+ <a-button v-bind="prefixProps" :class="{ active: open }" :icon="h(LinkOutlined)" @click="onTrigger()"></a-button>
|
|
|
+ <a-button v-if="!dirty" v-bind="prefixProps" @click="senderProps.onSubmit(command.no, false)">
|
|
|
+ {{ command.no }}
|
|
|
+ </a-button>
|
|
|
+ </a-space>
|
|
|
+ </template>
|
|
|
+ </Sender>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+:deep(.ant-sender-prefix) {
|
|
|
+ .ant-btn-text {
|
|
|
+ &.active {
|
|
|
+ color: #1677ff;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.row {
|
|
|
+ justify-content: space-evenly;
|
|
|
+ width: 100%;
|
|
|
+
|
|
|
+ .square-container {
|
|
|
+ position: relative;
|
|
|
+ padding-bottom: 100%;
|
|
|
+
|
|
|
+ .square {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .upload-button {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ //color: #999;
|
|
|
+ background-color: transparent;
|
|
|
+ box-shadow: 0 2px 0 rgba(255, 255, 255, 0.04);
|
|
|
+ border-style: dashed;
|
|
|
+
|
|
|
+ &:not(:hover) {
|
|
|
+ border-color: #424242;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.ant-col) {
|
|
|
+ $size: 256px;
|
|
|
+ max-width: $size;
|
|
|
+ text-align: center;
|
|
|
+
|
|
|
+ > label {
|
|
|
+ display: block;
|
|
|
+ margin-top: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .ant-image,
|
|
|
+ img {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ overflow: hidden;
|
|
|
+ object-fit: cover;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|