1518 lines
42 KiB
Plaintext
1518 lines
42 KiB
Plaintext
<template>
|
||
<view class="relative container">
|
||
<!-- 技能选择按钮 -->
|
||
<skill-tags-bar
|
||
v-if="isTaskAgent && enableSkillAt"
|
||
:selected-skills="selectedSkills"
|
||
@onRemove="removeSkill"
|
||
@onAdd="skillSelectModalVisible = true"
|
||
/>
|
||
<view class="flex flex-row items-center overflow-hide">
|
||
<!-- 手动选择组件 -->
|
||
<manual-component-item
|
||
v-if="
|
||
(manualComponents?.length && !isRecording) ||
|
||
(pageHomeIndex && expandPageArea === ExpandPageAreaEnum.Yes) ||
|
||
isTaskAgent ||
|
||
allowOtherModel
|
||
"
|
||
:manual-components="manualComponents || []"
|
||
:selected-components="selectedComponents"
|
||
:page-home-index="pageHomeIndex"
|
||
:expand-page-area="expandPageArea"
|
||
@onSelectComponent="handleToggleSelectComponent"
|
||
@onOpenPagePreview="handleOpenPagePreview"
|
||
@onOpenFileTree="handleOpenFileTree"
|
||
:is-task-agent="isTaskAgent"
|
||
:sandbox-list="sandboxList"
|
||
:current-sandbox-id="currentSandboxId"
|
||
:is-sandbox-switch-disabled="isSandboxSwitchDisabled"
|
||
:is-sandbox-unavailable="isSandboxUnavailable"
|
||
:sandbox-disabled-text="sandboxDisabledText"
|
||
:readonly="readonly"
|
||
:enable-skill-at="enableSkillAt"
|
||
@onSandboxChange="handleSandboxChange"
|
||
:allow-other-model="allowOtherModel"
|
||
:current-model-id="currentModelId"
|
||
:current-model-name="currentModelName"
|
||
:agent-id="agentId"
|
||
:agent-type="agentType"
|
||
:is-temp-chat="isTempChat"
|
||
@onModelChange="handleModelChange"
|
||
/>
|
||
</view>
|
||
|
||
<!--图片列表-->
|
||
<template v-if="files?.length">
|
||
<chat-upload-image
|
||
:files="files"
|
||
@onDel="handleDelFile"
|
||
@onAdd="handleAddFile"
|
||
:show-add-button="showAddButton"
|
||
/>
|
||
<view class="split-line" />
|
||
</template>
|
||
<view
|
||
class="record-tip-container"
|
||
:class="{ 'remove-shadow': isRecording }"
|
||
>
|
||
<!-- 无权限蒙层 -->
|
||
<view class="permission-mask" v-if="!hasPermission">
|
||
<text class="permission-text">{{
|
||
t("Mobile.Chat.noAgentPermission")
|
||
}}</text>
|
||
</view>
|
||
|
||
<!-- 订阅限制蒙层 -->
|
||
<!-- <view
|
||
class="permission-mask clickable"
|
||
v-else-if="subscriptionRequired"
|
||
@tap="handleOpenSubscription"
|
||
>
|
||
<text class="permission-text text-blue">{{
|
||
t("Mobile.Chat.subscriptionRequiredTip")
|
||
}}</text>
|
||
<text class="sub-arrow">➔</text>
|
||
</view> -->
|
||
|
||
<!-- 录音波形 -->
|
||
<view v-if="isRecording" class="waveform">
|
||
<view
|
||
v-for="(bar, index) in waveformBars"
|
||
:key="index"
|
||
class="waveform-bar"
|
||
:style="{
|
||
height: `${bar.height}px`,
|
||
animationDelay: `${bar.delay}ms`,
|
||
}"
|
||
/>
|
||
</view>
|
||
<view class="chat-input-container" :class="{ 'multi-line': isMultiLine }">
|
||
<voice-recorder-button
|
||
v-if="inputMethod === ChatInputMethod.Voice"
|
||
:duration="600000"
|
||
:is-conversation-active="isConversationActive"
|
||
@onRecordStart="handleVoiceStart"
|
||
@onRecordStop="handleVoiceStop"
|
||
@onError="handleVoiceError"
|
||
@onRecordCancel="handleRecordCancel"
|
||
@onRecordEnd="handleRecordEnd"
|
||
:format="'default'"
|
||
/>
|
||
<!--输入框-->
|
||
<!-- placeholder-class:微信小程序placeholder样式无效 -->
|
||
<textarea
|
||
v-else
|
||
ref="inputRef"
|
||
class="input"
|
||
:class="{ 'input-text': messageInfo?.length }"
|
||
placeholder-class="input-placeholder"
|
||
:value="messageInfo"
|
||
:focus="isInputFocused"
|
||
:cursor="textareaCursor"
|
||
@input="handleInput"
|
||
adjust-position="false"
|
||
@keyboardheightchange="onKeyboardheightchange"
|
||
:auto-height="true"
|
||
@focus="onInputFocus"
|
||
@blur="onInputBlur"
|
||
@confirm="handleSendMessage"
|
||
@linechange="handleLineChange"
|
||
:placeholder="t('Mobile.TempSession.inputPlaceholder')"
|
||
:show-confirm-bar="false"
|
||
:maxlength="-1"
|
||
:disable-default-padding="true"
|
||
>
|
||
</textarea>
|
||
<!-- 图标容器 -->
|
||
<view class="icon-container" v-if="!isRecording">
|
||
<!-- 如果不存在输入内容,则显示输入方式 -->
|
||
<template
|
||
v-if="!messageInfo || (messageInfo && messageInfo.trim() === '')"
|
||
>
|
||
<!-- 输入方式: 键盘 -->
|
||
<text
|
||
v-if="inputMethod === ChatInputMethod.Voice"
|
||
class="iconfont icon-keyboard font-48"
|
||
@click="handleChangeInputMethod"
|
||
/>
|
||
<!-- 输入方式: 语音 -->
|
||
<text
|
||
v-else
|
||
class="iconfont icon-voice font-48"
|
||
@click="handleChangeInputMethod"
|
||
/>
|
||
</template>
|
||
<!-- 正在会话中,则显示停止会话按钮 -->
|
||
<template v-if="isConversationActive">
|
||
<!-- 正在停止会话 loading -->
|
||
<uni-icons
|
||
v-if="isStoppingConversation"
|
||
class="icon-loop"
|
||
type="loop"
|
||
size="48rpx"
|
||
color="#999"
|
||
/>
|
||
<!-- 停止会话 -->
|
||
<text
|
||
v-else
|
||
class="iconfont icon-stop-circle icon-image font-48"
|
||
@click.stop.prevent="handleStopConversation"
|
||
/>
|
||
</template>
|
||
<!-- 如果存在输入内容或图片文件,则显示发送按钮。没有输入内容和图片文件,则显示添加图片或文件按钮 -->
|
||
<text
|
||
v-else-if="
|
||
(messageInfo?.length && messageInfo.trim() !== '') ||
|
||
files?.length
|
||
"
|
||
class="iconfont icon-send icon-send-image"
|
||
@click="handleSendMessage"
|
||
/>
|
||
<!-- 输入方式: 上传图片 -->
|
||
<text
|
||
v-else
|
||
class="iconfont icon-a-Pluscircle font-48"
|
||
@click="toggleExtraContainer"
|
||
/>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<!-- 功能栏 -->
|
||
<view
|
||
class="extra-container"
|
||
:class="{ show: showExtraContainer }"
|
||
v-if="showExtraContainer"
|
||
>
|
||
<view class="extra-box" @click="chooseImage('camera')">
|
||
<view class="icon-box">
|
||
<text class="iconfont icon-Camera" />
|
||
</view>
|
||
<text class="text">
|
||
{{ t("Mobile.Chat.takePhoto") }}
|
||
</text>
|
||
</view>
|
||
<view class="extra-box" @click="chooseImage('album')">
|
||
<view class="icon-box">
|
||
<text class="iconfont icon-Image" />
|
||
</view>
|
||
<text class="text">
|
||
{{ t("Mobile.Chat.album") }}
|
||
</text>
|
||
</view>
|
||
<view class="extra-box" @click="chooseFile">
|
||
<view class="icon-box">
|
||
<text class="iconfont icon-Folder" />
|
||
</view>
|
||
<text class="text">
|
||
{{ t("Mobile.Chat.file") }}
|
||
</text>
|
||
</view>
|
||
<view
|
||
class="extra-box"
|
||
v-if="isTaskAgent && enableSkillAt"
|
||
@click="chooseSkill"
|
||
>
|
||
<view class="icon-box">
|
||
<text class="iconfont icon-skill" />
|
||
</view>
|
||
<text class="text">
|
||
{{ t("Mobile.Chat.skill") }}
|
||
</text>
|
||
</view>
|
||
</view>
|
||
<!-- 开启键盘时,底部背景高度为键盘高度 -->
|
||
<view
|
||
class="ai-generate-text-container"
|
||
:class="{ 'ios-wechat': isIOSWechatMiniProgram }"
|
||
><text class="ai-generate-text">{{
|
||
t("Mobile.Chat.aiGeneratedDisclaimer")
|
||
}}</text></view
|
||
>
|
||
<view
|
||
class="keyboard-cover"
|
||
:style="{ height: keyboardHeight + 'px' }"
|
||
></view>
|
||
<!-- 技能选择模态框 -->
|
||
<skill-select-modal
|
||
:visible="skillSelectModalVisible"
|
||
@onClose="handleSkillModalClose"
|
||
@onSelect="handleSkillSelect"
|
||
/>
|
||
</view>
|
||
</template>
|
||
|
||
<script lang="uts" setup>
|
||
import SkillSelectModal from "./skill-select-modal/skill-select-modal.uvue";
|
||
import SkillTagsBar from "./skill-tags-bar/skill-tags-bar.uvue";
|
||
import { SkillInfoForAt } from "@/types/interfaces/skill";
|
||
import { SUCCESS_CODE } from "@/constants/codes.constants";
|
||
import { UploadFileInfo } from "@/types/interfaces/common.uts";
|
||
import { UploadFileStatus } from "@/types/enums/common.uts";
|
||
import ChatUploadImage from "@/components/chat-upload-image/chat-upload-image.uvue";
|
||
import ManualComponentItem from "./manual-component-item/manual-component-item.uvue";
|
||
import VoiceRecorderButton from "@/components/voice-recorder-button/voice-recorder-button.uvue";
|
||
import {
|
||
AgentManualComponentInfo,
|
||
AgentSelectedComponentInfo,
|
||
} from "@/types/interfaces/agent.uts";
|
||
import {
|
||
AgentComponentTypeEnum,
|
||
DefaultSelectedEnum,
|
||
AgentTypeEnum,
|
||
} from "@/types/enums/agent.uts";
|
||
import { ChatInputMethod } from "@/subpackages/types/enums/chat.uts";
|
||
import {
|
||
UPLOAD_FILE_ACTION,
|
||
DEFAULT_IMAGE_COUNT,
|
||
DEFAULT_FILE_COUNT,
|
||
CHAT_INPUT_METHOD,
|
||
} from "@/constants/common.constants";
|
||
import {
|
||
MicrophonePermissionHelper,
|
||
type PermissionState,
|
||
} from "@/utils/permissionHelper";
|
||
import { ExpandPageAreaEnum } from "@/types/enums/agent.uts";
|
||
import { ACCESS_TOKEN } from "@/constants/home.constants";
|
||
import { SandboxInfo } from "@/types/interfaces/sandbox";
|
||
import { useI18n } from "@/utils/i18n";
|
||
|
||
const { t } = useI18n();
|
||
|
||
// 文档
|
||
const files = ref<UploadFileInfo[]>([]);
|
||
// 输入内容
|
||
const messageInfo = ref<string>("");
|
||
// 已选技能列表
|
||
const selectedSkills = ref<SkillInfoForAt[]>([]);
|
||
// 技能选择模态框可见状态
|
||
const skillSelectModalVisible = ref<boolean>(false);
|
||
const isMultiLine = ref<boolean>(false);
|
||
// 键盘高度
|
||
const keyboardHeight = ref<number>(0);
|
||
// 是否显示额外功能栏
|
||
const showExtraContainer = ref<boolean>(false);
|
||
// 输入方式
|
||
const inputMethod = ref<ChatInputMethod>(ChatInputMethod.Input);
|
||
// 输入框ref
|
||
const inputRef = ref<UniInputElement | null>(null);
|
||
// 是否正在录音
|
||
const isRecording = ref<boolean>(false);
|
||
// 是否取消录音
|
||
const isCancelled = ref<boolean>(false);
|
||
// 已选组件列表
|
||
const selectedComponents = ref<AgentSelectedComponentInfo[]>([]);
|
||
// 录音时长计时器
|
||
let durationTimer: any = null;
|
||
|
||
// 波形动画数据
|
||
const waveformBars = ref([]);
|
||
// 是否是 iOS 微信小程序
|
||
const isIOSWechatMiniProgram = ref<boolean>(false);
|
||
// 标记是否由输入 @ 触发
|
||
const isAtTrigger = ref<boolean>(false);
|
||
// 控制输入框聚焦状态(兼容小程序)
|
||
const isInputFocused = ref<boolean>(false);
|
||
// 记录输入 @ 时的光标索引定位
|
||
const atCursorIndex = ref<number>(-1);
|
||
// 绑定文本框光标位置
|
||
const textareaCursor = ref<number>(-1);
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
manualComponents?: AgentManualComponentInfo[];
|
||
defaultSelectedComponents?: AgentSelectedComponentInfo[] | null;
|
||
// 会话是否正在进行中(有消息正在处理)
|
||
isConversationActive?: boolean;
|
||
// 停止操作是否正在进行中
|
||
isStoppingConversation?: boolean;
|
||
// 是否禁用发送按钮
|
||
wholeDisabled?: boolean;
|
||
// 首页页面URL
|
||
pageHomeIndex?: string;
|
||
// 是否默认展开扩展页面区域
|
||
expandPageArea?: number;
|
||
isTaskAgent?: boolean;
|
||
sandboxList?: SandboxInfo[];
|
||
currentSandboxId?: string;
|
||
isSandboxSwitchDisabled?: boolean;
|
||
isSandboxUnavailable?: boolean;
|
||
sandboxDisabledText?: string;
|
||
hasPermission?: boolean;
|
||
readonly?: boolean;
|
||
enableSkillAt?: boolean;
|
||
// 模型选择相关
|
||
allowOtherModel?: boolean;
|
||
currentModelId?: number;
|
||
currentModelName?: string;
|
||
agentId?: number;
|
||
agentType?: AgentTypeEnum;
|
||
isTempChat?: boolean;
|
||
subscriptionRequired?: boolean;
|
||
}>(),
|
||
{
|
||
manualComponents: () => [],
|
||
defaultSelectedComponents: () => null,
|
||
isConversationActive: false,
|
||
isStoppingConversation: false,
|
||
wholeDisabled: false,
|
||
isTaskAgent: false,
|
||
sandboxList: () => [],
|
||
currentSandboxId: "",
|
||
isSandboxSwitchDisabled: false,
|
||
isSandboxUnavailable: false,
|
||
sandboxDisabledText: "",
|
||
hasPermission: true,
|
||
readonly: false,
|
||
enableSkillAt: true,
|
||
allowOtherModel: false,
|
||
currentModelId: 0,
|
||
currentModelName: "",
|
||
agentId: 0,
|
||
agentType: AgentTypeEnum.ChatBot,
|
||
isTempChat: false,
|
||
subscriptionRequired: false,
|
||
},
|
||
);
|
||
|
||
// 暴露messageInfo和files
|
||
defineExpose({
|
||
files,
|
||
selectedComponents,
|
||
});
|
||
|
||
// 添加文件、图片等
|
||
const handleAddFile = () => {
|
||
// 显示额外功能栏
|
||
showExtraContainer.value = true;
|
||
};
|
||
|
||
// 声明事件
|
||
const emit = defineEmits<{
|
||
// 智能体主页和临时聊天页发送消息
|
||
onSendMessage: (data: {
|
||
messageInfo: string;
|
||
files: UploadFileInfo[];
|
||
selectedComponents: AgentSelectedComponentInfo[];
|
||
skillIds: number[];
|
||
}) => void;
|
||
onStopConversation: () => void;
|
||
onOpenPagePreview: (uri: string) => void;
|
||
// 打开工作台
|
||
onOpenFileTree: () => void;
|
||
onSandboxChange: (sandboxId: string) => void;
|
||
onModelChange: (modelId: number, name: string) => void;
|
||
onOpenSubscription: () => void;
|
||
}>();
|
||
|
||
// 切换沙盒
|
||
const handleSandboxChange = (sandboxId: string) => {
|
||
emit("onSandboxChange", sandboxId);
|
||
};
|
||
|
||
// 切换模型
|
||
const handleModelChange = (modelId: number, name: string) => {
|
||
emit("onModelChange", modelId, name);
|
||
};
|
||
|
||
// 触发打开订阅弹窗
|
||
const handleOpenSubscription = () => {
|
||
emit("onOpenSubscription");
|
||
};
|
||
|
||
// 打开文件树
|
||
const handleOpenFileTree = () => {
|
||
emit("onOpenFileTree");
|
||
};
|
||
|
||
// 停止会话
|
||
const handleStopConversation = () => {
|
||
showExtraContainer.value = false;
|
||
emit("onStopConversation");
|
||
};
|
||
|
||
const handleOpenPagePreview = (uri: string) => {
|
||
emit("onOpenPagePreview", uri);
|
||
};
|
||
|
||
// 初始化波形动画数据
|
||
const initWaveform = () => {
|
||
// Dynamically generate waveform bars based on 80% of width
|
||
const targetWidth = 640 * 0.8; // rpx
|
||
const barWidth = 6; // rpx
|
||
const gap = 4; // rpx
|
||
const numBars = Math.max(
|
||
3,
|
||
Math.floor((targetWidth + gap) / (barWidth + gap)),
|
||
); // At least 3 bars
|
||
|
||
waveformBars.value = Array.from({ length: numBars }, (_, index) => ({
|
||
height: Math.random() * 20 + 5, // Initial random height 5-25
|
||
delay: index * 100,
|
||
}));
|
||
};
|
||
|
||
// 确保组件挂载后也初始化波形和输入方式
|
||
onMounted(() => {
|
||
// 初始化波形动画数据
|
||
initWaveform();
|
||
|
||
// 从缓存中读取输入方式
|
||
const chatInputMethod = uni.getStorageSync(CHAT_INPUT_METHOD);
|
||
if (
|
||
chatInputMethod &&
|
||
(chatInputMethod === ChatInputMethod.Voice ||
|
||
chatInputMethod === ChatInputMethod.Input)
|
||
) {
|
||
inputMethod.value = chatInputMethod as ChatInputMethod;
|
||
}
|
||
|
||
// 判断是否是 iOS 微信小程序
|
||
// #ifdef MP-WEIXIN
|
||
try {
|
||
const systemInfo = uni.getSystemInfoSync();
|
||
|
||
if (systemInfo.osName === "ios") {
|
||
isIOSWechatMiniProgram.value = true;
|
||
}
|
||
} catch (e) {
|
||
console.error("获取系统信息失败:", e);
|
||
}
|
||
// #endif
|
||
|
||
// 小程序端首页页面,录音波形动画在开始录音时没有正常显示,需要延迟初始化,确保DOM已渲染
|
||
// #ifdef MP-WEIXIN
|
||
// 延迟初始化,确保DOM已渲染
|
||
setTimeout(() => {
|
||
if (waveformBars.value.length === 0) {
|
||
initWaveform();
|
||
}
|
||
}, 100);
|
||
// #endif
|
||
});
|
||
|
||
// 更新波形动画
|
||
const updateWaveform = () => {
|
||
waveformBars.value = waveformBars.value.map((bar) => ({
|
||
...bar,
|
||
height: Math.random() * 20 + 5,
|
||
}));
|
||
};
|
||
|
||
// 开始录音时长计时
|
||
const startDurationTimer = () => {
|
||
durationTimer = setInterval(() => {
|
||
updateWaveform();
|
||
}, 150);
|
||
};
|
||
|
||
// 停止录音时长计时
|
||
const stopDurationTimer = () => {
|
||
if (durationTimer) {
|
||
clearInterval(durationTimer);
|
||
durationTimer = null;
|
||
}
|
||
};
|
||
|
||
// 组件卸载时清理资源
|
||
onUnmounted(() => {
|
||
stopDurationTimer();
|
||
});
|
||
|
||
// 选择组件
|
||
const handleToggleSelectComponent = (item: AgentSelectedComponentInfo) => {
|
||
if (selectedComponents.value?.some((selected) => selected.id === item.id)) {
|
||
selectedComponents.value = selectedComponents.value.filter(
|
||
(selected) => selected.id !== item.id,
|
||
);
|
||
} else {
|
||
selectedComponents.value = [...selectedComponents.value, item];
|
||
}
|
||
};
|
||
|
||
// 监听 props 变化并更新 selectedComponents
|
||
watch(
|
||
() => [props.defaultSelectedComponents, props.manualComponents],
|
||
([newValue]) => {
|
||
if (newValue) {
|
||
selectedComponents.value = newValue;
|
||
} else {
|
||
selectedComponents.value = props.manualComponents
|
||
?.filter((item) => item.defaultSelected === DefaultSelectedEnum.Yes)
|
||
.map((item) => ({
|
||
id: item.id,
|
||
type: item.type,
|
||
}));
|
||
}
|
||
},
|
||
{ immediate: true, deep: true },
|
||
);
|
||
|
||
// 键盘高度变化
|
||
const onKeyboardheightchange = (res: UniInputKeyboardHeightChangeEvent) => {
|
||
// keyboardHeight.value = res.detail.height
|
||
|
||
// 微信小程序
|
||
// #ifdef MP-WEIXIN
|
||
if (res.detail.height > 31) {
|
||
keyboardHeight.value = showExtraContainer.value ? 30 + 115 : 30;
|
||
} else {
|
||
keyboardHeight.value = 0;
|
||
}
|
||
// #endif
|
||
|
||
// 非微信小程序
|
||
// #ifndef MP-WEIXIN
|
||
keyboardHeight.value = res.detail.height;
|
||
// #endif
|
||
};
|
||
|
||
// 生成文件唯一ID
|
||
const getFileUid = () => {
|
||
return Date.now().toString() + Math.random().toString(36).substr(2, 9);
|
||
};
|
||
|
||
// 计算已选择图片数量
|
||
const imageCount = computed(() => {
|
||
return (
|
||
files.value?.reduce(
|
||
(acc, file) => acc + (file.type?.includes("image/") ? 1 : 0),
|
||
0,
|
||
) || 0
|
||
);
|
||
});
|
||
|
||
// 计算已选择文件数量
|
||
const fileCount = computed(() => {
|
||
return (
|
||
files.value?.reduce(
|
||
(acc, file) => acc + !(file.type?.includes("image/") ? 1 : 0),
|
||
0,
|
||
) || 0
|
||
);
|
||
});
|
||
|
||
// 计算是否显示添加按钮
|
||
const showAddButton = computed(() => {
|
||
return (
|
||
imageCount.value < DEFAULT_IMAGE_COUNT ||
|
||
fileCount.value < DEFAULT_FILE_COUNT
|
||
);
|
||
});
|
||
|
||
// 选择文件
|
||
const chooseFile = () => {
|
||
// 如果已选择文件数量大于等于默认文件数量,则提示
|
||
if (fileCount.value >= DEFAULT_FILE_COUNT) {
|
||
uni.showToast({
|
||
position: "bottom",
|
||
title: t("Mobile.Chat.fileLimitReached", {
|
||
count: DEFAULT_FILE_COUNT,
|
||
}),
|
||
});
|
||
return;
|
||
}
|
||
// 可选择文件数量
|
||
const maxCount = DEFAULT_FILE_COUNT - fileCount.value;
|
||
uni.chooseFile({
|
||
type: "file",
|
||
count: maxCount,
|
||
success: (res) => {
|
||
// 当前选择文件数量
|
||
const uploadCount = res.tempFiles.length;
|
||
// 如果当前选择图片数量大于可选择图片数量,则返回
|
||
if (uploadCount > maxCount) {
|
||
uni.showToast({
|
||
title: t("Mobile.Chat.fileMaxSelect", { count: maxCount }),
|
||
icon: "none",
|
||
duration: 2000,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const _files = res.tempFiles.map((file) => ({
|
||
url: file.path,
|
||
name: file.name,
|
||
type: file.type,
|
||
size: file.size,
|
||
status: UploadFileStatus.uploading,
|
||
uid: getFileUid(),
|
||
}));
|
||
files.value = [...files.value, ..._files] as UploadFileInfo[];
|
||
|
||
// 一次性上传所有选中的图片
|
||
const filePaths = res.tempFiles.map((file) => file.path);
|
||
uploadMultipleFiles(filePaths);
|
||
},
|
||
fail: (err) => {
|
||
console.error("choose file: " + err.errMsg);
|
||
},
|
||
});
|
||
};
|
||
|
||
// 选择技能
|
||
const chooseSkill = () => {
|
||
showExtraContainer.value = false;
|
||
skillSelectModalVisible.value = true;
|
||
};
|
||
|
||
// 选中技能
|
||
const handleSkillSelect = (skill: SkillInfoForAt) => {
|
||
skillSelectModalVisible.value = false; // 关闭选择技能弹框
|
||
|
||
if (!selectedSkills.value.some((s) => s.id === skill.id)) {
|
||
selectedSkills.value.push(skill);
|
||
}
|
||
|
||
let text = messageInfo.value || "";
|
||
const index = atCursorIndex.value;
|
||
let targetCursor: number = -1;
|
||
|
||
if (
|
||
isAtTrigger.value &&
|
||
index !== undefined &&
|
||
index >= 1 &&
|
||
index <= text.length
|
||
) {
|
||
// 在光标位置原地补全
|
||
const before = text.substring(0, index - 1);
|
||
const after = text.substring(index);
|
||
const addedText = `@${skill.name} `;
|
||
text = before + addedText + after;
|
||
targetCursor = before.length + addedText.length;
|
||
} else {
|
||
// 降级处理(末尾追加)
|
||
let tempText = text;
|
||
if (isAtTrigger.value && tempText.endsWith("@")) {
|
||
tempText = tempText.slice(0, -1);
|
||
}
|
||
const prefix =
|
||
tempText && tempText.length > 0
|
||
? tempText.endsWith(" ")
|
||
? ""
|
||
: " "
|
||
: "";
|
||
const addedText = prefix + `@${skill.name} `;
|
||
text = tempText + addedText;
|
||
targetCursor = tempText.length + addedText.length;
|
||
}
|
||
|
||
messageInfo.value = text;
|
||
const triggerWasActive = isAtTrigger.value;
|
||
isAtTrigger.value = false; // 重置状态
|
||
atCursorIndex.value = -1; // 重置光标位置
|
||
|
||
if (triggerWasActive && targetCursor >= 0) {
|
||
textareaCursor.value = targetCursor;
|
||
}
|
||
|
||
// #ifdef MP-WEIXIN
|
||
isInputFocused.value = false;
|
||
setTimeout(() => {
|
||
isInputFocused.value = true;
|
||
setTimeout(() => {
|
||
textareaCursor.value = -1;
|
||
}, 50);
|
||
}, 50);
|
||
// #endif
|
||
|
||
// #ifndef MP-WEIXIN
|
||
nextTick(() => {
|
||
if (inputRef.value && typeof inputRef.value?.focus === "function") {
|
||
inputRef.value.focus();
|
||
setTimeout(() => {
|
||
textareaCursor.value = -1;
|
||
}, 100);
|
||
}
|
||
});
|
||
// #endif
|
||
};
|
||
|
||
// 关闭技能选择框时的回调
|
||
const handleSkillModalClose = () => {
|
||
skillSelectModalVisible.value = false;
|
||
|
||
if (isAtTrigger.value && atCursorIndex.value >= 0) {
|
||
textareaCursor.value = atCursorIndex.value;
|
||
|
||
// #ifdef MP-WEIXIN
|
||
isInputFocused.value = false;
|
||
setTimeout(() => {
|
||
isInputFocused.value = true;
|
||
setTimeout(() => {
|
||
textareaCursor.value = -1;
|
||
}, 50);
|
||
}, 50);
|
||
// #endif
|
||
|
||
// #ifndef MP-WEIXIN
|
||
nextTick(() => {
|
||
if (inputRef.value && typeof inputRef.value?.focus === "function") {
|
||
inputRef.value.focus();
|
||
setTimeout(() => {
|
||
textareaCursor.value = -1;
|
||
}, 100);
|
||
}
|
||
});
|
||
// #endif
|
||
}
|
||
|
||
isAtTrigger.value = false;
|
||
atCursorIndex.value = -1;
|
||
};
|
||
|
||
// 移除技能
|
||
const removeSkill = (index: number) => {
|
||
const skill = selectedSkills.value[index];
|
||
if (skill != null) {
|
||
selectedSkills.value.splice(index, 1);
|
||
const mentionWithSpace = `@${skill.name} `;
|
||
const mention = `@${skill.name}`;
|
||
|
||
let text = messageInfo.value || "";
|
||
|
||
// 本地变量操作,避免对 messageInfo.value 作多次 Response 促发
|
||
while (text.includes(mentionWithSpace)) {
|
||
text = text.replace(mentionWithSpace, "");
|
||
}
|
||
while (text.includes(mention)) {
|
||
text = text.replace(mention, "");
|
||
}
|
||
messageInfo.value = text;
|
||
}
|
||
};
|
||
|
||
// 拍照或相册选择图片
|
||
const chooseImage = (type: string) => {
|
||
// 如果已选择图片数量大于等于默认图片数量,则提示
|
||
if (imageCount.value >= DEFAULT_IMAGE_COUNT) {
|
||
uni.showToast({
|
||
position: "bottom",
|
||
title: t("Mobile.Chat.imageLimitReached", {
|
||
count: DEFAULT_IMAGE_COUNT,
|
||
}),
|
||
});
|
||
return;
|
||
}
|
||
// 可选择图片数量
|
||
const maxCount = DEFAULT_IMAGE_COUNT - imageCount.value;
|
||
uni.chooseImage({
|
||
sourceType: [type === "camera" ? "camera" : "album"],
|
||
sizeType: ["original", "compressed"],
|
||
count: maxCount,
|
||
success: (res) => {
|
||
// 当前选择图片数量
|
||
const uploadCount = res.tempFiles.length;
|
||
// 如果当前选择图片数量大于可选择图片数量,则返回
|
||
if (uploadCount > maxCount) {
|
||
uni.showToast({
|
||
title: t("Mobile.Chat.imageMaxSelect", { count: maxCount }),
|
||
icon: "none",
|
||
duration: 2000,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const _files = res.tempFiles.map((file) => ({
|
||
url: file.path,
|
||
name: file.name,
|
||
type: file.type,
|
||
size: file.size,
|
||
status: UploadFileStatus.uploading,
|
||
uid: getFileUid(),
|
||
}));
|
||
files.value = [...files.value, ..._files] as UploadFileInfo[];
|
||
|
||
// 一次性上传所有选中的图片
|
||
const filePaths = res.tempFiles.map((file) => file.path);
|
||
uploadMultipleFiles(filePaths);
|
||
},
|
||
fail: (err) => {
|
||
// 如果取消选择图片,则不显示错误提示
|
||
if (err.errMsg.includes("cancel")) {
|
||
return;
|
||
}
|
||
uni.showToast({
|
||
title: t("Mobile.Chat.chooseImageFailed", {
|
||
reason: err?.errMsg || "",
|
||
}),
|
||
position: "bottom",
|
||
});
|
||
},
|
||
});
|
||
};
|
||
|
||
// 上传单张图片(返回Promise)
|
||
const uploadFile = (
|
||
filePath: string,
|
||
uid: string,
|
||
): Promise<UploadFileInfo> => {
|
||
// 令牌
|
||
const token = uni.getStorageSync(ACCESS_TOKEN) ?? "";
|
||
const header = {};
|
||
// #ifdef H5 || WEB
|
||
if (process.env.NODE_ENV === "development") {
|
||
header["Authorization"] = `Bearer ${token}`;
|
||
}
|
||
// #endif
|
||
|
||
// #ifdef MP-WEIXIN
|
||
header["Authorization"] = `Bearer ${token}`;
|
||
// #endif
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const fileName = filePath.split("/").pop() || "image.jpg";
|
||
const uploadTask: UploadTask = uni.uploadFile({
|
||
url: UPLOAD_FILE_ACTION,
|
||
filePath: filePath,
|
||
name: "file",
|
||
header,
|
||
formData: {
|
||
type: "tmp",
|
||
},
|
||
success: (res) => {
|
||
try {
|
||
const result = JSON.parse(res.data);
|
||
const { code, data, message } = result;
|
||
if (code === SUCCESS_CODE) {
|
||
const uploadedFile: UploadFileInfo = {
|
||
key: data?.key || "",
|
||
name: data?.fileName || fileName,
|
||
type: data?.mimeType || "image/jpeg",
|
||
size: data?.size || 0,
|
||
url: data?.url || "",
|
||
uid,
|
||
status: UploadFileStatus.done,
|
||
percent: 100,
|
||
response: result,
|
||
};
|
||
resolve(uploadedFile);
|
||
} else {
|
||
reject(
|
||
new Error(
|
||
data?.message ||
|
||
message ||
|
||
t("Mobile.AudioUploader.uploadFailed"),
|
||
),
|
||
);
|
||
}
|
||
} catch (error) {
|
||
console.error("解析上传响应失败:", error);
|
||
reject(error);
|
||
}
|
||
},
|
||
fail: (error) => {
|
||
// console.error('上传失败:', error);
|
||
reject(error);
|
||
},
|
||
});
|
||
|
||
// 上传进度
|
||
uploadTask.onProgressUpdate((res: OnProgressUpdateResult) => {
|
||
const index = files.value.findIndex((file) => file.uid === uid);
|
||
if (index !== -1) {
|
||
files.value[index].percent = res.progress;
|
||
}
|
||
});
|
||
});
|
||
};
|
||
|
||
// 多文件上传
|
||
const uploadMultipleFiles = async (filePaths: string[]) => {
|
||
if (filePaths.length === 0) return;
|
||
|
||
try {
|
||
// 并发上传所有图片
|
||
const uploadPromises = filePaths.map((filePath) => {
|
||
// 找到对应的文件以获取uid
|
||
const file = files.value?.find((f) => f.url === filePath);
|
||
return uploadFile(filePath, file?.uid || "");
|
||
});
|
||
|
||
// 使用 Promise.allSettled 处理部分成功、部分失败的情况
|
||
const uploadResults = await Promise.allSettled(uploadPromises);
|
||
|
||
// 处理上传结果
|
||
uploadResults.forEach((result, index) => {
|
||
const filePath = filePaths[index];
|
||
const file = files.value?.find((f) => f.url === filePath);
|
||
// 上传成功
|
||
if (result.status === "fulfilled") {
|
||
// 上传成功,更新文件状态
|
||
const uploadedFile = result.value;
|
||
const fileIndex = files.value?.findIndex(
|
||
(f) => f.uid === uploadedFile.uid,
|
||
);
|
||
if (fileIndex !== -1) {
|
||
files.value[fileIndex] = uploadedFile;
|
||
}
|
||
} else {
|
||
// 上传失败,更新对应文件状态为 error
|
||
if (file) {
|
||
const fileIndex = files.value?.findIndex((f) => f.uid === file.uid);
|
||
if (fileIndex !== -1) {
|
||
files.value[fileIndex] = {
|
||
...files.value[fileIndex],
|
||
status: UploadFileStatus.error,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// 隐藏额外功能栏
|
||
showExtraContainer.value = false;
|
||
} catch (error) {
|
||
console.error("批量上传处理失败:", error);
|
||
}
|
||
};
|
||
|
||
// 输入框获取焦点
|
||
const onInputFocus = () => {
|
||
isInputFocused.value = true;
|
||
// 隐藏额外功能栏
|
||
showExtraContainer.value = false;
|
||
// 滚动到最后一个消息
|
||
props.onScrollToLastMsg?.(true);
|
||
};
|
||
|
||
// 输入框失去焦点
|
||
const onInputBlur = () => {
|
||
isInputFocused.value = false;
|
||
// 隐藏额外功能栏
|
||
showExtraContainer.value = false;
|
||
};
|
||
|
||
// 切换额外功能栏显示/隐藏
|
||
const toggleExtraContainer = () => {
|
||
showExtraContainer.value = !showExtraContainer.value;
|
||
};
|
||
|
||
// 打开麦克风权限
|
||
const openMicrophonePermission = async () => {
|
||
// #ifdef H5
|
||
try {
|
||
const permissionState = await MicrophonePermissionHelper.check();
|
||
console.log("切换到语音模式, 检查麦克风权限", permissionState);
|
||
if (permissionState !== "granted") {
|
||
MicrophonePermissionHelper.request();
|
||
}
|
||
} catch (error) {
|
||
console.error("切换到语音模式, 检查麦克风权限失败:", error);
|
||
}
|
||
// #endif
|
||
|
||
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ
|
||
// 小程序端检查录音权限
|
||
try {
|
||
const authResult = await uni.authorize({
|
||
scope: "scope.record",
|
||
});
|
||
} catch (error: any) {
|
||
// 权限被拒绝,引导用户手动开启
|
||
uni.showModal({
|
||
title: t("Mobile.Voice.permissionRequired"),
|
||
content: t("Mobile.Voice.permissionContent"),
|
||
confirmText: t("Mobile.Voice.goToSettings"),
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
uni.openSetting({
|
||
success: (settingRes) => {
|
||
if (settingRes.authSetting["scope.record"]) {
|
||
// 用户开启了权限,可以重新尝试录音
|
||
}
|
||
},
|
||
});
|
||
}
|
||
},
|
||
});
|
||
}
|
||
// #endif
|
||
};
|
||
|
||
// 切换输入方式
|
||
const handleChangeInputMethod = async () => {
|
||
// if (props.isConversationActive.value) return;
|
||
if (inputMethod.value === ChatInputMethod.Voice) {
|
||
inputMethod.value = ChatInputMethod.Input;
|
||
uni.setStorageSync(CHAT_INPUT_METHOD, ChatInputMethod.Input);
|
||
// 隐藏额外功能栏
|
||
showExtraContainer.value = false;
|
||
nextTick(() => {
|
||
if (inputRef.value && typeof inputRef.value?.focus === "function") {
|
||
inputRef.value.focus();
|
||
}
|
||
});
|
||
} else {
|
||
// 切换到语音模式
|
||
inputMethod.value = ChatInputMethod.Voice;
|
||
uni.setStorageSync(CHAT_INPUT_METHOD, ChatInputMethod.Voice);
|
||
inputRef.value = null;
|
||
}
|
||
};
|
||
|
||
// 监听输入方式变化
|
||
watch(
|
||
inputMethod,
|
||
(newVal) => {
|
||
if (newVal === ChatInputMethod.Voice) {
|
||
openMicrophonePermission();
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
// 输入事件
|
||
const handleInput = (e: UniInputEvent) => {
|
||
const value = e.detail.value ?? "";
|
||
|
||
const prevValue = messageInfo.value || "";
|
||
|
||
const cursor = e.detail.cursor;
|
||
// 兼容判定:如果取不到 cursor fallback 到 endsWith
|
||
const isAtTyped =
|
||
cursor !== undefined
|
||
? cursor > 0 && value.substring(cursor - 1, cursor) === "@"
|
||
: value.endsWith("@");
|
||
const isAddingOne = value.length === prevValue.length + 1;
|
||
|
||
// 输入 @ 弹出技能框
|
||
if (props.isTaskAgent && props.enableSkillAt && isAddingOne && isAtTyped) {
|
||
uni.hideKeyboard(); // 提前收起键盘
|
||
isAtTrigger.value = true;
|
||
atCursorIndex.value = cursor ?? -1;
|
||
showExtraContainer.value = false;
|
||
skillSelectModalVisible.value = true;
|
||
}
|
||
messageInfo.value = value;
|
||
selectedSkills.value = selectedSkills.value.filter(
|
||
(s: SkillInfoForAt): boolean => value.includes(`@${s.name}`),
|
||
);
|
||
};
|
||
|
||
// 点击发送事件
|
||
const handleSendMessage = () => {
|
||
if (props.wholeDisabled) {
|
||
// 未填写必填参数时,清空输入内容,让用户可以再次切换输入方式
|
||
messageInfo.value = "";
|
||
uni.showToast({
|
||
title: t("Mobile.Chat.fillRequiredParams"),
|
||
icon: "none",
|
||
duration: 2000,
|
||
});
|
||
return;
|
||
}
|
||
// 判断是否存在上传失败的图片或文件
|
||
const existErrorFile = files.value?.some(
|
||
(file) => file.status === UploadFileStatus.error,
|
||
);
|
||
if (existErrorFile) {
|
||
uni.showToast({
|
||
title: t("Mobile.Chat.removeUploadFailed"),
|
||
icon: "none",
|
||
duration: 1500,
|
||
});
|
||
return;
|
||
}
|
||
if (
|
||
(messageInfo.value && messageInfo.value?.trim()) ||
|
||
files.value?.length > 0 ||
|
||
selectedSkills.value.length > 0
|
||
) {
|
||
const data = {
|
||
messageInfo: messageInfo.value,
|
||
files: files.value,
|
||
selectedComponents: selectedComponents.value,
|
||
skillIds: selectedSkills.value.map((s) => s.targetId),
|
||
};
|
||
|
||
const pages = getCurrentPages();
|
||
// 当前页面
|
||
const currentPage = pages[pages.length - 1];
|
||
// 当前页面是首页
|
||
if (currentPage.route.includes("home")) {
|
||
uni.$emit("homeVoiceRecorderStart", data);
|
||
} else {
|
||
// 当前页面不是首页(智能体主页、临时聊天页),发送消息
|
||
emit("onSendMessage", data);
|
||
}
|
||
|
||
// 置空
|
||
selectedSkills.value = [];
|
||
files.value = [];
|
||
messageInfo.value = "";
|
||
}
|
||
};
|
||
|
||
// 语音录制开始
|
||
const handleVoiceStart = () => {
|
||
isRecording.value = true;
|
||
startDurationTimer();
|
||
};
|
||
|
||
// 语音录制结束并转换为文字
|
||
const handleVoiceStop = async (transcriptText: string) => {
|
||
isRecording.value = false;
|
||
stopDurationTimer();
|
||
if (transcriptText && typeof transcriptText === "string") {
|
||
// 将语音转换的文字作为消息发送
|
||
messageInfo.value = transcriptText;
|
||
await nextTick();
|
||
handleSendMessage();
|
||
}
|
||
};
|
||
|
||
// 语音录制错误
|
||
const handleVoiceError = (error: any) => {
|
||
isRecording.value = false;
|
||
stopDurationTimer();
|
||
let errorMessage = t("Mobile.Voice.recordFailed");
|
||
if (error.message) {
|
||
errorMessage = error.message;
|
||
} else if (error.errMsg) {
|
||
errorMessage = error.errMsg;
|
||
}
|
||
|
||
uni.showToast({
|
||
title: errorMessage,
|
||
icon: "none",
|
||
duration: 2000,
|
||
});
|
||
};
|
||
|
||
// 取消录音
|
||
// const handleCancelChange = (value: boolean) => {
|
||
// isCancelled.value = value;
|
||
// }
|
||
|
||
// Add @onRecordCancel to voice-recorder-button
|
||
const handleRecordCancel = () => {
|
||
isRecording.value = false;
|
||
isCancelled.value = false; // Optional: reset cancel state
|
||
};
|
||
|
||
// Add handler
|
||
const handleRecordEnd = () => {
|
||
isRecording.value = false;
|
||
isCancelled.value = false;
|
||
};
|
||
|
||
// Add handler for line change
|
||
const handleLineChange = (e: UniTextareaLineChangeEvent) => {
|
||
isMultiLine.value = e.detail.lineCount > 1;
|
||
};
|
||
|
||
// 删除文档
|
||
const handleDelFile = (uid: string) => {
|
||
files.value = files.value?.filter((file) => file.uid !== uid) || [];
|
||
};
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.container {
|
||
padding: 16rpx 32rpx 0 32rpx;
|
||
gap: 20rpx;
|
||
background-color: #fff;
|
||
border-radius: 16rpx 16rpx 0 0;
|
||
// 确保容器为相对定位,用于容纳绝对定位的蒙层
|
||
position: relative;
|
||
overflow: visible;
|
||
|
||
.split-line {
|
||
width: 100%;
|
||
height: 1rpx;
|
||
background-color: #f0f0f0;
|
||
}
|
||
|
||
.record-tip-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 100%;
|
||
border-radius: 24rpx;
|
||
box-shadow:
|
||
0 9rpx 45rpx 0 rgba(0, 0, 0, 0.08),
|
||
0 2rpx 10rpx 0 rgba(0, 0, 0, 0.08),
|
||
0 0 3rpx 0 rgba(0, 0, 0, 0.08);
|
||
position: relative;
|
||
// #ifdef H5
|
||
border: 1rpx solid rgba(0, 0, 0, 0.15);
|
||
// #endif
|
||
|
||
&.remove-shadow {
|
||
box-shadow: none;
|
||
border-radius: 0;
|
||
border-color: transparent;
|
||
}
|
||
|
||
.waveform {
|
||
height: 70rpx;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 4rpx;
|
||
width: 100%;
|
||
|
||
.waveform-bar {
|
||
width: 6rpx;
|
||
background-color: rgb(108, 101, 244);
|
||
border-radius: 3rpx;
|
||
transition: height 0.3s ease;
|
||
}
|
||
}
|
||
}
|
||
|
||
.chat-input-container {
|
||
width: 100%;
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
align-self: stretch;
|
||
min-height: 90rpx;
|
||
|
||
.input {
|
||
width: 552rpx;
|
||
// width: 90%;
|
||
// min-height: 44rpx;
|
||
// line-height: 32rpx; // 过小导致闪烁
|
||
line-height: 44rpx; // 恢复正常行高
|
||
max-height: 320rpx;
|
||
margin: 10rpx 0 10rpx 10rpx;
|
||
// 彻底移除垂直 padding,消除底部留白
|
||
padding: 0 10rpx;
|
||
border: none;
|
||
font-size: 32rpx;
|
||
font-weight: 400;
|
||
color: #000;
|
||
box-sizing: border-box;
|
||
resize: none;
|
||
outline: none;
|
||
|
||
// #ifndef MP-WEIXIN
|
||
// 允许纵向滚动,但隐藏滚动条样式 (H5等)
|
||
overflow-y: auto;
|
||
// #endif
|
||
|
||
&.input-text {
|
||
width: 90%;
|
||
}
|
||
|
||
// 隐藏滚动条 - 兼容各浏览器
|
||
scrollbar-width: none; // Firefox
|
||
-ms-overflow-style: none; // IE/Edge
|
||
&::-webkit-scrollbar {
|
||
display: none;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
}
|
||
|
||
.input-placeholder {
|
||
// line-height: 44rpx;
|
||
color: rgba(0, 0, 0, 0.25);
|
||
font-size: 28rpx;
|
||
font-weight: 400;
|
||
}
|
||
|
||
.icon-container {
|
||
position: absolute;
|
||
right: 0rpx;
|
||
bottom: 4rpx;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
// gap: 32rpx;
|
||
// height: 50rpx;
|
||
z-index: 50;
|
||
background-color: #fff;
|
||
|
||
.iconfont {
|
||
padding: 15rpx;
|
||
}
|
||
|
||
.icon-image {
|
||
color: #5147ff;
|
||
}
|
||
|
||
.icon-send-image {
|
||
color: #5147ff !important;
|
||
font-size: 48rpx;
|
||
}
|
||
}
|
||
}
|
||
|
||
.chat-input-container.multi-line {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 16rpx;
|
||
|
||
.input {
|
||
width: 87%;
|
||
line-height: 44rpx;
|
||
}
|
||
|
||
.icon-container {
|
||
// position: inherit;
|
||
align-self: flex-end; // Align icons to right or adjust as needed
|
||
flex-direction: row;
|
||
justify-content: flex-end;
|
||
}
|
||
}
|
||
}
|
||
|
||
.extra-container {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 32rpx;
|
||
justify-content: flex-start;
|
||
// padding: 8rpx 32rpx;
|
||
max-height: 0;
|
||
overflow: hidden;
|
||
transition:
|
||
max-height 0.3s ease-in-out,
|
||
padding 0.3s ease-in-out,
|
||
opacity 0.3s ease-in-out;
|
||
opacity: 0;
|
||
|
||
&.show {
|
||
max-height: 200rpx;
|
||
// padding: 8rpx 32rpx;
|
||
opacity: 1;
|
||
}
|
||
|
||
.extra-box {
|
||
// width: 164rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
|
||
.icon-box {
|
||
width: 112rpx;
|
||
height: 112rpx;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
border-radius: 16rpx;
|
||
background-color: #f5f5f5;
|
||
|
||
.iconfont {
|
||
font-size: 48rpx;
|
||
color: #333;
|
||
}
|
||
}
|
||
|
||
.text {
|
||
font-size: 28rpx;
|
||
color: #15171f;
|
||
font-weight: 400;
|
||
line-height: 44rpx;
|
||
}
|
||
}
|
||
}
|
||
|
||
.icon-loop {
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
from {
|
||
transform: rotate(0deg);
|
||
}
|
||
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
// 键盘弹出后,垫高底部。默认占位大小为底部安全区域高度
|
||
.keyboard-cover {
|
||
// Android 的键盘高度不包含安全区域高度,其他平台包含
|
||
// #ifdef APP-ANDROID
|
||
margin-bottom: env(safe-area-inset-bottom);
|
||
// #endif
|
||
// #ifndef APP-ANDROID
|
||
padding-bottom: env(safe-area-inset-bottom);
|
||
// #endif
|
||
}
|
||
.ai-generate-text-container {
|
||
margin-top: -15rpx;
|
||
margin-bottom: -15rpx;
|
||
// iOS 微信小程序特殊样式
|
||
&.ios-wechat {
|
||
position: absolute;
|
||
bottom: -24rpx;
|
||
left: 0;
|
||
right: 0;
|
||
margin-bottom: env(safe-area-inset-bottom);
|
||
}
|
||
}
|
||
.ai-generate-text {
|
||
font-size: 18rpx;
|
||
color: #999;
|
||
line-height: 32rpx;
|
||
text-align: center;
|
||
}
|
||
|
||
.permission-mask {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: rgba(255, 255, 255, 0.9);
|
||
z-index: 99;
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: center;
|
||
align-items: center;
|
||
// 匹配父容器圆角
|
||
border-radius: 24rpx;
|
||
// backdrop-filter: blur(2px); // 兼容性较差,可能不需要
|
||
|
||
&.clickable {
|
||
cursor: pointer;
|
||
background-color: rgba(247, 250, 255, 0.95);
|
||
border: 1px dashed rgba(0, 122, 255, 0.3);
|
||
padding: 0 16rpx;
|
||
|
||
&:active {
|
||
background-color: rgba(235, 243, 255, 0.98);
|
||
}
|
||
}
|
||
|
||
.permission-text {
|
||
font-size: 28rpx;
|
||
color: #ccc;
|
||
|
||
&.text-blue {
|
||
color: #007aff;
|
||
font-weight: 500;
|
||
text-align: center;
|
||
}
|
||
}
|
||
|
||
.sub-arrow {
|
||
color: #007aff;
|
||
font-size: 32rpx;
|
||
margin-left: 12rpx;
|
||
animation: bounceRight 1.5s infinite ease-in-out;
|
||
}
|
||
}
|
||
|
||
@keyframes bounceRight {
|
||
0%,
|
||
100% {
|
||
transform: translateX(0);
|
||
}
|
||
50% {
|
||
transform: translateX(6rpx);
|
||
}
|
||
}
|
||
</style>
|