Files
qiming/qiming-mobile/subpackages/pages/agent-search/agent-search.uvue

742 lines
21 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="search-page-container flex flex-col">
<!-- 微信安全区 -->
<safety-zone></safety-zone>
<!-- 搜索框 -->
<view class="search-container border-b">
<!-- #ifdef MP-WEIXIN -->
<text class="cancel-text" @click="jumpNavigateBack">{{
t("Mobile.Common.cancel")
}}</text>
<!-- #endif -->
<view class="search-box" :class="{ focused: isFocused }">
<text class="iconfont icon-Search"></text>
<input
ref="searchInputRef"
class="search-input"
:placeholder="searchPlaceholder"
placeholder-class="placeholder-class"
type="search"
confirm-type="search"
:value="searchKeyword"
@confirm="handleSearch($event.detail.value)"
@input="handleSearch($event.detail.value)"
@focus="handleFocus"
@blur="handleBlur"
:auto-focus="shouldAutoFocus"
/>
<template v-if="searchKeyword">
<text
class="iconfont icon-a-Xcircle-fill clear-icon"
@click="handleSearch('')"
></text>
</template>
</view>
<!-- #ifndef MP-WEIXIN -->
<text class="cancel-text" @click="jumpNavigateBack">{{
t("Mobile.Common.cancel")
}}</text>
<!-- #endif -->
<!-- 微信胶囊按钮功能区 -->
<!-- #ifdef MP-WEIXIN -->
<view
class="right-safety"
:style="{
width: clientRect.width + 13 + 'px',
height: clientRect.height + 'px',
}"
></view>
<!-- #endif -->
</view>
<!-- 滚动部分 -->
<view class="scroll-section flex-1">
<scroll-view
class="h-full"
@scrolltolower="handleLoadMore"
:refresher-enabled="searchMode === 'api'"
:refresher-triggered="refreshing || false"
@refresherrefresh="handleRefresh"
:show-scrollbar="false"
>
<template v-if="(agentList || []).length">
<template v-for="info in agentList" :key="info.id">
<!-- 页面应用 -->
<page-card
v-if="cardComponentType === 'PageApp'"
:coverImg="info.coverImg"
:name="info.name"
:userName="info.publishUser?.userName"
:created="info.created"
@click="handleAgentClick(info)"
/>
<!-- 首页 -->
<recent-used-agent-item
v-else-if="cardComponentType === 'Home'"
:icon="info.icon"
:name="info.name"
:description="info.description"
:paymentRequired="info.paymentRequired && enableSubscription === 1"
:subscribed="info.subscribed"
@click="handleRecentUsedAgentClick(info)"
/>
<!-- 聊天机器人: 默认 cardComponentType 为 ChatBot -->
<agent-component
v-else
:icon="info.icon"
:name="info.name"
:avatar="info.publishUser?.avatar"
:userName="
info.publishUser?.nickName || info.publishUser?.userName
"
:description="info.description"
:userCount="info.statistics?.userCount"
:convCount="info.statistics?.convCount"
:collectCount="info.statistics?.collectCount"
:paymentRequired="info.paymentRequired && enableSubscription === 1"
:subscribed="info.subscribed"
@click="handleAgentClick(info)"
/>
</template>
</template>
<template v-if="loading">
<view class="loading-state">
<text class="loading-text">{{ t("Mobile.Page.loadingMore") }}</text>
</view>
</template>
<template
v-else-if="!loading && searchKeyword && agentList?.length === 0"
>
<view class="empty-state">
<image
:src="noData"
class="empty-image"
:alt="t('Mobile.Common.noDataImageAlt')"
/>
<text class="empty-text">{{
t("Mobile.AgentSearch.emptyResult")
}}</text>
</view>
</template>
<template v-if="showNoMore">
<view class="no-more-state">
<text class="no-more-text">{{
t("Mobile.Common.noMoreData")
}}</text>
</view>
</template>
</scroll-view>
</view>
</view>
</template>
<script lang="uts" setup>
import { apiPublishedAgentList } from "@/servers/square";
import { apiUserUsedAgentList } from "@/servers/agentDev";
import {
SquarePublishedListParams,
SquarePublishedItemInfo,
} from "@/types/interfaces/square";
import { SquareAgentTypeEnum } from "@/types/enums/square";
import { Page, RequestResponse } from "@/types/interfaces/request";
import { SUCCESS_CODE } from "@/constants/codes.constants";
import { AgentComponentTypeEnum } from "@/types/enums/agent";
import RecentUsedAgentItem from "@/components/recent-used-agent-item/recent-used-agent-item.uvue";
import { apiTenantConfig } from "@/servers/account";
import { TENANT_CONFIG_INFO } from "@/constants/home.constants";
import type { TenantConfigInfo } from "@/types/interfaces/login";
import {
jumpToAgentDetailPage,
getCurrentPagePath,
} from "@/utils/commonBusiness";
import type { AgentInfo } from "@/types/interfaces/agent";
import noData from "@/static/assets/no_data.png";
// import { jumpNavigateBack } from '@/utils/common'
import type { CategoryItemInfo } from "@/types/interfaces/agentConfig";
import { AllowCopyEnum } from "@/types/enums/agent";
import { PluginTypeEnum } from "@/types/enums/plugin";
import { isTabPage } from "@/utils/commonBusiness";
import { setCurrentPageNavigationBarTitle } from "@/utils/system.uts";
import { useI18n, translateText } from "@/utils/i18n";
const { t } = useI18n();
const searchKeyword = ref<string>("");
const searchTimeoutRef = ref<number | null>(null);
const agentList = ref<SquarePublishedItemInfo[] | AgentInfo[]>([]);
const hasMore = ref<boolean>(true);
const page = ref<number>(1);
const loading = ref<boolean>(false);
const refreshing = ref<boolean>(false);
// 搜索输入框ref
const searchInputRef = ref<UniInputElement | null>(null);
const isFocused = ref<boolean>(false);
const cardComponentType = ref<"Home" | "PageApp" | "ChatBot">("ChatBot");
// 租户信息
const tenantConfigInfo = ref<TenantConfigInfo | null>(null);
// 租户订阅限制强受控标志
const enableSubscription = computed((): number => {
return tenantConfigInfo.value?.enableSubscription ?? 0;
});
// 搜索模式:'api' 接口搜索模式,'local' 前端过滤模式
const searchMode = ref<"api" | "local">("api");
// 前端过滤模式下的原始列表数据
const localAgentList = ref<CategoryItemInfo[]>([]);
// 搜索框 placeholder key运行时翻译保证语言切换可生效
const searchPlaceholderSource = ref<string>("Mobile.AgentSearch.placeholder");
const searchPlaceholder = computed(() => {
// 默认占位符 Key
let placeholderKey = "Mobile.AgentSearch.placeholder";
// 如果是 PageApp 类型,切换 Key
if (cardComponentType.value === "PageApp") {
placeholderKey = "Mobile.AgentSearch.pageAppPlaceholder";
}
// 如果 searchPlaceholderSource 有值且不是默认值(通常是 local 模式下从外部传入),则优先使用它
const finalKey =
searchPlaceholderSource.value &&
searchPlaceholderSource.value !== "Mobile.AgentSearch.placeholder"
? searchPlaceholderSource.value
: placeholderKey;
return translateText(finalKey);
});
// 小程序平台控制 auto-focus
const shouldAutoFocus = ref<boolean>(false);
// 返回地址
const backUrl = ref<string>("");
// #ifdef MP-WEIXIN
const clientRect = uni.getMenuButtonBoundingClientRect();
// #endif
const jumpNavigateBack = () => {
const toUrl = backUrl.value ? backUrl.value : "/pages/index/index";
console.log(toUrl);
if (isTabPage(toUrl)) {
uni.switchTab({ url: toUrl });
} else {
uni.redirectTo({ url: toUrl });
}
};
// 获取租户配置
const fetchTenantConfig = async () => {
const { code, data } = await apiTenantConfig();
if (code === SUCCESS_CODE) {
tenantConfigInfo.value = data;
// 缓存租户信息
uni.setStorageSync(TENANT_CONFIG_INFO, JSON.stringify(data));
}
};
// 初始化租户配置数据
const initTenantConfig = async () => {
// 1. 首先尝试从本地缓存读取
const tenantConfigInfoString = await uni.getStorageSync(TENANT_CONFIG_INFO);
if (tenantConfigInfoString) {
tenantConfigInfo.value = JSON.parse(tenantConfigInfoString);
}
// 2. 然后必须发起网络强同步请求,以获得最新的 enableSubscription 状态
await fetchTenantConfig();
};
onLoad(
async (options: {
type: "Home" | "PageApp" | "ChatBot";
searchMode?: "api" | "local";
backUrl?: string;
}) => {
// 强同步获取并更新租户配置信息,保障收费 tag 状态的百分百实时受控
await initTenantConfig();
const { type, searchMode: mode } = options;
backUrl.value = options.backUrl
? decodeURIComponent(options.backUrl)
: "";
cardComponentType.value = type;
// 设置当前页面导航栏标题
setCurrentPageNavigationBarTitle();
// 检测 searchMode 参数,区分搜索模式
if (mode === "local") {
// 从 globalData 获取列表数据和文案
const app = getApp();
// 兜底逻辑:如果 globalData 没有相关数据,回退到 api 模式
if (
app.globalData &&
app.globalData.localAgentList &&
Array.isArray(app.globalData.localAgentList) &&
app.globalData.localAgentList.length > 0
) {
// 有数据,使用前端过滤模式
searchMode.value = "local";
localAgentList.value = app.globalData
.localAgentList as CategoryItemInfo[];
// 默认不显示全部列表,只有搜索时才显示
agentList.value = [];
hasMore.value = false;
// 获取搜索文案,如果没有则使用默认值
if (app.globalData.searchPlaceholder) {
searchPlaceholderSource.value = app.globalData
.searchPlaceholder as string;
}
} else {
// 没有数据,回退到 api 模式
searchMode.value = "api";
}
} else {
searchMode.value = "api";
}
},
);
onMounted(() => {
// 页面加载完成后自动获取输入框焦点
// #ifndef MP-WEIXIN
// 非小程序平台可以直接调用 focus
nextTick(() => {
if (
searchInputRef.value &&
typeof searchInputRef.value.focus === "function"
) {
searchInputRef.value.focus();
}
});
// #endif
});
// 小程序平台使用 onReady 确保页面渲染完成后再聚焦
onReady(() => {
// #ifdef MP-WEIXIN
// 小程序平台:通过设置 auto-focus 为 true 来触发聚焦
shouldAutoFocus.value = true;
// #endif
});
// 小程序页面显示时也尝试聚焦
onShow(() => {
// #ifdef MP-WEIXIN
// 小程序平台:通过重置 auto-focus 来重新触发聚焦
shouldAutoFocus.value = false;
nextTick(() => {
shouldAutoFocus.value = true;
});
// #endif
});
// 是否显示没有更多数据(前端过滤模式下不显示)
const showNoMore = computed(() => {
if (searchMode.value === "local") {
return false;
}
return !loading.value && !hasMore.value && agentList.value?.length > 8;
});
/**
* 将 CategoryItemInfo 转换为 SquarePublishedItemInfo 格式
* @param items CategoryItemInfo 数组
* @returns SquarePublishedItemInfo 数组
*/
const convertCategoryItemsToSquareItems = (
items: CategoryItemInfo[],
): SquarePublishedItemInfo[] => {
return items.map((item) => ({
id: item.targetId, // 使用 targetId 作为 id
tenantId: 0,
spaceId: 0,
targetType: item.targetType as SquareAgentTypeEnum,
agentType: item.agentType,
targetId: item.targetId,
name: item.name,
description: item.description || null,
icon: item.icon,
remark: "",
modified: "",
created: "",
statistics: item.statistics,
publishUser: item.publishUser,
category: "",
allowCopy: AllowCopyEnum.No,
pluginType: PluginTypeEnum.HTTP,
collect: item.collect,
paymentRequired: (item as any).paymentRequired,
subscribed: (item as any).subscribed,
}));
};
const handleFocus = () => {
isFocused.value = true;
};
const handleBlur = () => {
isFocused.value = false;
};
// 获取智能体、页面应用列表
const fetchAgentList = async (
pageNum: number = 1,
keyword: string = searchKeyword.value,
) => {
const params: SquarePublishedListParams = {
targetType: AgentComponentTypeEnum.Agent,
targetSubType:
cardComponentType.value === "PageApp" ? "PageApp" : "ChatBot",
page: pageNum,
pageSize: 10,
category: "",
kw: keyword,
};
// 获取智能体列表
const response = await apiPublishedAgentList(params);
const { code, data, message } = response as unknown as RequestResponse<
Page<SquarePublishedItemInfo>
>;
// 成功
if (code === SUCCESS_CODE) {
const newList = data.records || [];
if (pageNum > 1) {
agentList.value = [...agentList.value, ...newList];
} else {
agentList.value = newList;
}
hasMore.value = pageNum < data.pages;
page.value = pageNum;
loading.value = false;
} else {
uni.showToast({
title: message || t("Mobile.AgentSearch.fetchAgentFailed"),
icon: "error",
});
loading.value = false;
}
};
// 查询用户最近使用过的智能体列表
const fetchUserUsedAgentList = async (
pageNum: number = 1,
keyword: string = searchKeyword.value,
) => {
const res = await apiUserUsedAgentList({
pageIndex: pageNum,
size: 20,
keyword,
});
const { code, data } = res || {};
if (code === SUCCESS_CODE) {
const newList = data || [];
if (pageNum > 1) {
// 追加数据
agentList.value = [...(agentList.value || []), ...newList];
} else {
// 替换数据
agentList.value = newList;
}
// 是否有更多数据, 如果数据长度大于0则有更多数据
hasMore.value = newList?.length > 0;
page.value = pageNum;
loading.value = false;
} else {
uni.showToast({
title: t("Mobile.AgentSearch.fetchRecentUsedFailed"),
icon: "none",
});
loading.value = false;
}
};
// 获取智能体列表
const fetchList = async (
pageNum: number = 1,
keyword: string = searchKeyword.value,
) => {
// 前端过滤模式下不调用接口
if (searchMode.value === "local") {
return;
}
if (cardComponentType.value === "Home") {
await fetchUserUsedAgentList(pageNum, keyword);
} else {
await fetchAgentList(pageNum, keyword);
}
};
/**
* 前端过滤搜索(本地过滤)
* @param keyword 搜索关键词
*/
const filterLocalList = (keyword: string) => {
// 如果没有关键词,返回空列表
if (!keyword || !localAgentList.value.length) {
agentList.value = [];
return;
}
// 对本地列表进行过滤,匹配 name 或 description不区分大小写
const lowerKeyword = keyword.toLowerCase();
const filtered = localAgentList.value.filter((item) => {
const nameMatch =
item.name?.toLowerCase().includes(lowerKeyword) || false;
const descMatch =
item.description?.toLowerCase().includes(lowerKeyword) || false;
return nameMatch || descMatch;
});
agentList.value = convertCategoryItemsToSquareItems(filtered);
};
// 处理搜索
const handleSearch = (keyword: string) => {
searchKeyword.value = keyword;
// 清除之前的定时器
if (searchTimeoutRef.value) {
clearTimeout(searchTimeoutRef.value);
}
// 如果搜索关键词为空
if (!keyword) {
// 前端过滤模式和接口搜索模式都清空列表
agentList.value = [];
loading.value = false;
// #ifdef H5 || WEB
nextTick(() => {
if (searchInputRef.value) {
searchInputRef.value?.focus();
}
});
// #endif
return;
}
// 根据搜索模式选择不同的处理方式
if (searchMode.value === "local") {
// 前端过滤模式:本地过滤
loading.value = true;
// 设置新的定时器防抖500ms
searchTimeoutRef.value = setTimeout(() => {
filterLocalList(keyword);
loading.value = false;
}, 500);
} else {
// 接口搜索模式:调用接口
loading.value = true;
// 设置新的定时器防抖500ms
searchTimeoutRef.value = setTimeout(() => {
page.value = 1;
hasMore.value = true;
fetchList(1, keyword);
}, 500);
}
};
// 加载更多
const handleLoadMore = () => {
// 前端过滤模式下禁用加载更多
if (searchMode.value === "local") {
return;
}
if (hasMore.value && !loading.value) {
const nextPage = page.value + 1;
fetchList(nextPage);
}
};
// 下拉刷新
const handleRefresh = () => {
// 前端过滤模式下禁用下拉刷新
if (searchMode.value === "local") {
refreshing.value = false;
return;
}
if (refreshing.value) {
return;
}
refreshing.value = true;
fetchList().finally(() => {
refreshing.value = false;
});
};
// 跳转到智能体详情页
const handleAgentClick = (info: SquarePublishedItemInfo) => {
jumpToAgentDetailPage(info.targetId, null, info.agentType, info.name);
};
// 跳转到智能体详情页
const handleRecentUsedAgentClick = (info: AgentInfo) => {
jumpToAgentDetailPage(info.agentId, null, info.agentType, info.name);
};
</script>
<style lang="scss" scoped>
.search-page-container {
height: 100vh;
overflow: hidden;
background-color: #fff;
/* #ifdef MP-WEIXIN */
// padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
/* #endif */
/* 搜索框 */
.search-container {
display: flex;
flex-direction: row;
align-items: center;
padding: 10rpx 0 10rpx 32rpx;
flex-shrink: 0;
.search-box {
flex: 1;
gap: 16rpx;
display: flex;
align-items: center;
flex-direction: row;
height: 76rpx;
padding: 0 24rpx;
border-radius: 16rpx;
border: 2rpx solid transparent;
background: rgba(12, 20, 102, 0.04);
transition: border-color 0.3s ease;
&.focused {
border-color: #5147ff;
}
.iconfont {
font-size: 32rpx;
color: #666;
}
.search-input {
flex: 1;
border: none;
font-size: 28rpx;
color: #15171f;
outline: none;
font-weight: 400;
}
.placeholder-class {
color: rgba(21, 23, 31, 0.5);
font-size: 28rpx;
font-weight: 400;
}
.clear-icon {
font-size: 32rpx;
color: #999 !important;
cursor: pointer;
}
}
.cancel-text {
display: flex;
align-items: center;
justify-content: center;
height: 76rpx;
font-weight: 400;
padding: 0 32rpx;
color: rgba(21, 23, 31, 0.5);
font-size: 32rpx;
transition: background-color 0.3s ease;
border-radius: 8rpx;
&:active {
background-color: rgba(12, 20, 102, 0.04);
}
/* #ifdef H5 */
&:hover {
cursor: pointer;
}
/* #endif */
}
}
/* 滚动部分 */
.scroll-section {
flex: 1;
overflow: hidden;
/* 空状态 */
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
.empty-image {
width: 172rpx;
height: 172rpx;
}
.empty-text {
font-size: 32rpx;
color: #15171f;
font-weight: 400;
margin-top: 32rpx;
}
}
/* 加载状态 */
.loading-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
.loading-text {
font-size: 28rpx;
color: #666;
display: flex;
align-items: center;
}
.loading-text::before {
content: "";
width: 32rpx;
height: 32rpx;
border: 4rpx solid #e8e8e8;
border-top: 4rpx solid #8b5cf6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 16rpx;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 没有更多数据状态 */
.no-more-state {
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
}
.no-more-text {
font-size: 24rpx;
color: #999;
}
}
}
</style>